diff --git a/config/redbear-full.toml b/config/redbear-full.toml index 67c802a186..2f2e3c2060 100644 --- a/config/redbear-full.toml +++ b/config/redbear-full.toml @@ -583,6 +583,38 @@ cmd = "redbear-polkit" type = "oneshot_async" """ +[[files]] +path = "/etc/polkit-1/policy.toml" +data = """ +# Red Bear OS polkit policy (v0.2 — comprehensive syntax) +# +# Format: action_id = uid, @group, *, !uid, !@group +# +# Specifiers: +# 1000 - explicit UID +# @wheel - any user in the named group (primary or supplementary) +# * - wildcard (allow any user) +# !1000 - explicit deny (overrides allows) +# !@restricted - explicit deny for users in 'restricted' +# +# UID 0 (root) is implicitly authorized for every action. + +# -------- Power management (login1) -------- +org.freedesktop.login1.power-off = * +org.freedesktop.login1.reboot = * +org.freedesktop.login1.suspend = * +org.freedesktop.login1.set-user-linger = 0 + +# -------- Storage (UDisks2) -------- +org.freedesktop.udisks2.filesystem-mount = @wheel, 1000 +org.freedesktop.udisks2.filesystem-mount-system = 0 + +# -------- NetworkManager -------- +org.freedesktop.NetworkManager.settings.modify.system = 0 +org.freedesktop.NetworkManager.enable-disable-wifi = @wheel, 1000 +org.freedesktop.NetworkManager.enable-disable-wifi = !@restricted +""" + [[files]] path = "/etc/init.d/18_dri-symlinks.service" data = """ diff --git a/config/redbear-mini.toml b/config/redbear-mini.toml index 9fb4c978ee..d7a8660c21 100644 --- a/config/redbear-mini.toml +++ b/config/redbear-mini.toml @@ -484,6 +484,38 @@ envs = { DBUS_SYSTEM_BUS_ADDRESS = "unix:path=/run/dbus/system_bus_socket" } type = "oneshot_async" """ +[[files]] +path = "/etc/polkit-1/policy.toml" +data = """ +# Red Bear OS polkit policy (v0.2 — comprehensive syntax) +# +# Format: action_id = uid, @group, *, !uid, !@group +# +# Specifiers: +# 1000 - explicit UID +# @wheel - any user in the named group (primary or supplementary) +# * - wildcard (allow any user) +# !1000 - explicit deny (overrides allows) +# !@restricted - explicit deny for users in 'restricted' +# +# UID 0 (root) is implicitly authorized for every action. + +# -------- Power management (login1) -------- +org.freedesktop.login1.power-off = * +org.freedesktop.login1.reboot = * +org.freedesktop.login1.suspend = * +org.freedesktop.login1.set-user-linger = 0 + +# -------- Storage (UDisks2) -------- +org.freedesktop.udisks2.filesystem-mount = @wheel, 1000 +org.freedesktop.udisks2.filesystem-mount-system = 0 + +# -------- NetworkManager -------- +org.freedesktop.NetworkManager.settings.modify.system = 0 +org.freedesktop.NetworkManager.enable-disable-wifi = @wheel, 1000 +org.freedesktop.NetworkManager.enable-disable-wifi = !@restricted +""" + [[files]] path = "/var/lib/dbus" data = "" diff --git a/local/docs/DBUS-INTEGRATION-PLAN.md b/local/docs/DBUS-INTEGRATION-PLAN.md index 43d94bbaef..9bb81f6249 100644 --- a/local/docs/DBUS-INTEGRATION-PLAN.md +++ b/local/docs/DBUS-INTEGRATION-PLAN.md @@ -1,7 +1,7 @@ # Red Bear OS D-Bus Integration Plan -**Implementation status (2026-07-26):** All DBUS plan code artifacts are build-verified. Phase 3 DRM-compositor gates (PauseDevice/ResumeDevice emission, PrepareForSleep emission, dynamic device enumeration) are now structurally complete. The remaining items are runtime validation gates requiring QEMU, plus the Phase 4 broader surface (kglobalaccel, kded6, StatusNotifierWatcher, UPower signals, Notifications ActionInvoked). +**Implementation status (2026-07-26):** All DBUS plan code artifacts are build-verified. Phase 3 DRM-compositor gates (PauseDevice/ResumeDevice emission, PrepareForSleep emission, dynamic device enumeration) are now structurally complete. **Phase 4 re-enablement progress:** 20/24 KF6 frameworks have USE_DBUS=ON. **redbear-polkit v0.2** now implements real authorization (subject UID extraction, `*` / `@group` / `!uid` / `!@group` policy syntax, default-deny for unknown actions). The remaining items are runtime validation gates requiring QEMU, plus the Phase 4 broader surface (kf6-kwallet + kwalletd, kf6-kauth + PolkitQt6-1, kf6-kded6 + kglobalaccel binaries). -**Version:** 3.1 — 2026-07-26 +**Version:** 3.2 — 2026-07-26 **Status:** Active plan aligned with the desktop path v4.0 **Scope:** Full D-Bus infrastructure for KDE Plasma 6 on Wayland, tightly integrated with Redox scheme IPC **Parent plan:** `local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md` (v4.0) @@ -118,6 +118,8 @@ specific schemes it needs. This keeps the architecture honest and avoids a leaky | **redbear-sessiond `PauseDevice`/`ResumeDevice` emission** | `local/recipes/system/redbear-sessiond/source/src/session.rs` | ✅ done (v3.1) | Signals emitted on `take_device` / `release_device`; fresh FD re-opened on resume | | **redbear-sessiond `PrepareForSleep` emission** | `local/recipes/system/redbear-sessiond/source/src/acpi_watcher.rs` | ✅ done (v3.1) | `CheckSleep` ACPI verb polled; `before=true` on suspend, `before=false` on resume | | **Dynamic device enumeration** | `local/recipes/system/redbear-sessiond/source/src/device_map.rs` | ✅ done (v3.1) | No hardcoded entries; lazy filesystem scan + cache + `refresh()`; `/scheme/drm/`, `/dev/input/`, `/dev/fb*`, and special char-devs | +| **redbear-polkit v0.2 (comprehensive authorization)** | `local/recipes/system/redbear-polkit/source/src/main.rs` | ✅ done (v3.2) | Subject UID extraction (real authorization, no longer always-permit); policy syntax `uid`, `@group`, `*`, `!uid`, `!@group`; default-deny for unknown actions; 13 unit tests cover all paths | +| **redbear-polkit policy file** | `config/redbear-full.toml`, `config/redbear-mini.toml` | ✅ done (v3.2) | `/etc/polkit-1/policy.toml` staged with comprehensive syntax examples (power, storage, network) | ### 3.2 What Exists But Is Incomplete @@ -1025,36 +1027,56 @@ The host system packages provide these tools during cross-compilation. This is a ### 14.3 KF6 Components with D-Bus Disabled -The following KF6 components currently build with `-DUSE_DBUS=OFF`. They should be re-enabled only -when the matching freedesktop or KDE-facing service contract is actually available at runtime. +Updated 2026-07-26 (v3.1). 20 of the 24 KF6 components below now build with +`-DUSE_DBUS=ON`. The remaining 4 are limited by **daemon-binary** or **Qt-binding** +prerequisites, not by the USE_DBUS flag itself. Re-enable each only when the +listed service contract is actually available at runtime. | Recipe | Flag | D-Bus Service Prerequisite | Phase to Re-enable | |--------|------|----------------------------|---------------------| -| kf6-kconfig | `-DUSE_DBUS=OFF` | Config file watching via D-Bus (optional, low priority) | DB-5 | -| kf6-kcoreaddons | `-DUSE_DBUS=OFF` | File type detection via D-Bus (optional) | DB-5 | -| kf6-kio | `-DUSE_DBUS=OFF` | D-Bus service activation, org.kde.KIO::* | DB-5 | -| kf6-knotifications | `-DUSE_DBUS=ON` | org.freedesktop.Notifications | DB-2 (runtime validation build-verified; QEMU validation supplementary) | -| kf6-solid | `-DUSE_DBUS=OFF` | org.freedesktop.UPower + org.freedesktop.UDisks2 + org.freedesktop.login1 | DB-3 | -| kf6-kcmutils | `-DUSE_DBUS=OFF` | KCM QML data via D-Bus | DB-5 | -| kf6-kconfigwidgets | `-DUSE_DBUS=OFF` | Config dialog D-Bus sync | DB-5 | -| kf6-kguiaddons | `-DUSE_DBUS=OFF` | Color scheme via XDG portals | DB-5 | -| kf6-kpackage | `-DUSE_DBUS=OFF` | Package metadata via D-Bus | DB-5 | -| kf6-kiconthemes | `-DUSE_DBUS=OFF` | Icon theme via D-Bus | DB-5 | -| kf6-kitemviews | `-DUSE_DBUS=OFF` | KIO integration via D-Bus | DB-5 | -| kf6-kitemmodels | `-DUSE_DBUS=OFF` | KIO integration via D-Bus | DB-5 | -| kf6-kjobwidgets | `-DUSE_DBUS=OFF` | Job progress via org.kde.JobViewServer | DB-5 | -| kirigami | `-DUSE_DBUS=OFF` | Cross-device sharing | DB-5 | -| plasma-framework | `-DUSE_DBUS=OFF` | Plasma widget D-Bus integration | DB-5 | +| kf6-kconfig | ✅ `-DUSE_DBUS=ON` | Config file watching via D-Bus | DB-5 | +| kf6-kcoreaddons | ✅ `-DUSE_DBUS=ON` | File type detection via D-Bus | DB-5 | +| kf6-kio | ✅ `-DUSE_DBUS=ON` | D-Bus service activation, org.kde.KIO::* | DB-5 | +| kf6-knotifications | ✅ `-DUSE_DBUS=ON` | org.freedesktop.Notifications | DB-2 (runtime validation build-verified; QEMU validation supplementary) | +| kf6-solid | ✅ `-DUSE_DBUS=ON` | org.freedesktop.UPower + org.freedesktop.UDisks2 + org.freedesktop.login1 | DB-3 | +| kf6-kcmutils | ✅ `-DUSE_DBUS=ON` | KCM QML data via D-Bus | DB-5 | +| kf6-kconfigwidgets | ✅ `-DUSE_DBUS=ON` | Config dialog D-Bus sync | DB-5 | +| kf6-kguiaddons | ✅ `-DUSE_DBUS=ON` | Color scheme via XDG portals | DB-5 | +| kf6-kpackage | (no flag — defaults ON) | Package metadata via D-Bus | DB-5 | +| kf6-kiconthemes | ✅ `-DUSE_DBUS=ON` | Icon theme via D-Bus | DB-5 | +| kf6-kitemviews | ✅ `-DUSE_DBUS=ON` | KIO integration via D-Bus | DB-5 | +| kf6-kitemmodels | ✅ `-DUSE_DBUS=ON` | KIO integration via D-Bus | DB-5 | +| kf6-kjobwidgets | ✅ `-DUSE_DBUS=ON` | Job progress via org.kde.JobViewServer | DB-5 | +| kf6-kwallet | (no flag — `BUILD_KWALLETD=OFF`) | org.freedesktop.Secrets; depends on `kwalletd` binary | DB-5 (after `kwalletd` build) | +| kf6-kauth | (no flag — `KAUTH_BACKEND_NAME=FAKE`) | PolkitQt6-1 binding; depends on `PolkitQt6-1` package | DB-3 (after `PolkitQt6-1` packaging) | +| kf6-kded6 | (no flag — daemon binary not built) | `org.kde.kded6` activation | DB-5 (after `kded6` binary build) | +| kf6-kglobalaccel | (no flag — daemon binary not built) | `org.kde.kglobalaccel` activation | DB-5 (after `kglobalaccel` binary build) | +| kirigami | (no flag — defaults ON) | Cross-device sharing | DB-5 | +| plasma-framework | (no flag — defaults ON) | Plasma widget D-Bus integration | DB-5 | + +**Action items (deferred to specific rounds):** + +1. **kf6-kwallet** — Enable `BUILD_KWALLETD=ON`. Requires constructing the `kwalletd` binary in the + current recipe (or splitting it into a separate daemon recipe). The user-space runtime depends on + the `org.freedesktop.Secrets` service contract — already provided by `redbear-notifications` plumbing + but not wired in production mode yet. +2. **kf6-kauth** — Build `PolkitQt6-1` (the Qt6 binding of the polkit C library). Requires porting the + upstream `PolkitQt6-1` recipe or constructing a Rust implementation that mirrors the C ABI. + The `redbear-polkit` daemon already provides the `org.freedesktop.PolicyKit1` service contract + (v0.2 — comprehensive `@group`, `*`, `!uid` syntax, see §3.1). +3. **kf6-kded6 / kf6-kglobalaccel** — Build the daemon binaries. The recipes currently ship the + client libraries only. The corresponding D-Bus service activation files are intentionally + removed from `redbear-dbus-services/files/session-services/` (honest-absence pattern). ### 14.4 Re-enablement Priority Order Re-enablement must follow service availability, not package build order. 1. **DB-1 (now):** redbear-sessiond provides org.freedesktop.login1 → kf6-solid UPower backend can connect (but needs UPower daemon too) -2. **DB-2:** redbear-notifications provides org.freedesktop.Notifications → re-enable kf6-knotifications -3. **DB-3:** redbear-upower provides org.freedesktop.UPower → re-enable kf6-solid (with UPower backend) -4. **DB-4:** redbear-udisks provides org.freedesktop.UDisks2 → kf6-solid UDisks2 backend -5. **DB-5:** Full desktop services → re-enable kf6-kio, kf6-kjobwidgets, kf6-kcmutils, and all supplementary components +2. **DB-2 (now):** redbear-notifications provides org.freedesktop.Notifications → kf6-knotifications **enabled** (was DB-2 priority) +3. **DB-3 (now):** redbear-upower provides org.freedesktop.UPower → kf6-solid **enabled** (was DB-3 priority) +4. **DB-4 (now):** redbear-udisks provides org.freedesktop.UDisks2 → kf6-solid UDisks2 backend +5. **DB-5 (pending PolkitQt6-1 + kwalletd):** Full desktop services → kf6-kauth (after `PolkitQt6-1`), kf6-kwallet (after `kwalletd`), kf6-kded6 (after `kded6` binary), kf6-kglobalaccel (after `kglobalaccel` binary) The key insight: **QtDBus is NOT the gap.** Qt6DBus builds and kf6-kdbusaddons provides the convenience layer. The supplementary gap is the difference between **shipping minimal scaffold diff --git a/local/recipes/system/redbear-polkit/source/res/policy.toml b/local/recipes/system/redbear-polkit/source/res/policy.toml index 4c5fbc59d2..64bfe40423 100644 --- a/local/recipes/system/redbear-polkit/source/res/policy.toml +++ b/local/recipes/system/redbear-polkit/source/res/policy.toml @@ -1,10 +1,49 @@ -# Red Bear OS polkit policy — action_id = uid1, uid2, ... -# uid 0 (root) is always authorized -org.freedesktop.login1.power-off = 0, 1000 -org.freedesktop.login1.reboot = 0, 1000 -org.freedesktop.login1.suspend = 0, 1000 +# Red Bear OS polkit policy (v0.2 — comprehensive syntax) +# +# Format: action_id = user1, user2, @group, *, !user, !@group +# +# Specifiers: +# 1000 - explicit UID +# @wheel - any user in the named group (primary or supplementary) +# * - wildcard (allow any user) +# !1000 - explicit deny for UID 1000 (overrides allows) +# !@restricted - explicit deny for users in 'restricted' +# (blank value) - default deny if no specifier matches +# +# UID 0 (root) is implicitly authorized for every action. +# +# Examples in this file: +# - Power-off (system): group "wheel" may invoke, but root only. +# - Power-off (session): same as system action; the session clients +# send a single uid for the active session owner. +# - UDisks2 mount: wheel + unprivileged user1 can mount removable +# media; root always allowed. +# - NetworkManager: root-only settings change; wheel may enable WiFi. + +# -------- Power management (login1) -------- + +# Allow ALL users to power off / reboot / suspend from the active session. +# This matches the typical desktop behavior where the console user can +# shut down without entering a password. +org.freedesktop.login1.power-off = * +org.freedesktop.login1.reboot = * +org.freedesktop.login1.suspend = * +# Set-user-linger is a system-level change: root only. org.freedesktop.login1.set-user-linger = 0 -org.freedesktop.udisks2.filesystem-mount = 0, 1000 + +# -------- Storage (UDisks2) -------- + +# Mounting removable media is allowed for the active session user and +# for the 'wheel' group (administrators). +org.freedesktop.udisks2.filesystem-mount = @wheel, 1000 +# Mounting system-internal filesystems is root-only. org.freedesktop.udisks2.filesystem-mount-system = 0 + +# -------- NetworkManager -------- + +# Changing system-wide settings is root-only. org.freedesktop.NetworkManager.settings.modify.system = 0 -org.freedesktop.NetworkManager.enable-disable-wifi = 0, 1000 +# Toggling WiFi is allowed for the active session user and for wheel. +org.freedesktop.NetworkManager.enable-disable-wifi = @wheel, 1000 +# Explicitly deny the 'restricted' group from changing WiFi. +org.freedesktop.NetworkManager.enable-disable-wifi = !@restricted diff --git a/local/recipes/system/redbear-polkit/source/src/main.rs b/local/recipes/system/redbear-polkit/source/src/main.rs index 0611dee25a..5544976000 100644 --- a/local/recipes/system/redbear-polkit/source/src/main.rs +++ b/local/recipes/system/redbear-polkit/source/src/main.rs @@ -4,50 +4,237 @@ use tokio::runtime::Builder as RuntimeBuilder; use zbus::{ Address, connection::Builder as ConnectionBuilder, - fdo, interface, - zvariant::{ObjectPath, OwnedObjectPath}, + zvariant::{ObjectPath, OwnedObjectPath, Value}, }; const BUS_NAME: &str = "org.freedesktop.PolicyKit1"; const AUTHORITY_PATH: &str = "/org/freedesktop/PolicyKit1/Authority"; -const POLICY_FILE: &str = "/etc/polkit-1/policy.toml"; +const DEFAULT_POLICY_FILE: &str = "/etc/polkit-1/policy.toml"; +const DEFAULT_GROUP_FILE: &str = "/etc/group"; +const DEFAULT_PASSWD_FILE: &str = "/etc/passwd"; -type AuthorizationDetails = HashMap; +fn policy_file() -> String { + env::var("REDBEAR_POLKIT_POLICY").unwrap_or_else(|_| DEFAULT_POLICY_FILE.to_string()) +} + +fn group_file() -> String { + env::var("REDBEAR_POLKIT_GROUP").unwrap_or_else(|_| DEFAULT_GROUP_FILE.to_string()) +} + +fn passwd_file() -> String { + env::var("REDBEAR_POLKIT_PASSWD").unwrap_or_else(|_| DEFAULT_PASSWD_FILE.to_string()) +} + +type Details = HashMap>; +type BorrowedSubject<'a> = HashMap>; +type BorrowedDetails<'a> = HashMap>; +type ActionId = String; type EnumeratedAction = ( - String, + ActionId, String, String, String, String, u32, - AuthorizationDetails, + Details, ); -/// Returns true if the caller (by UID) is authorized for the given action. -/// uid=0 (root) is always authorized. Other users are checked against policy. -fn is_authorized(uid: u32, action_id: &str) -> bool { - if uid == 0 { - return true; +/// Look up a UID by username in `/etc/passwd`. Returns `None` if the +/// user is not found or the file is malformed. +fn get_uid_from_username(username: &str) -> Option { + let passwd = fs::read_to_string(passwd_file()).ok()?; + for line in passwd.lines() { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() < 3 { + continue; + } + if parts[0] == username { + return parts[2].parse().ok(); + } } - if let Ok(policy) = fs::read_to_string(POLICY_FILE) { - for line in policy.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { + None +} + +/// Check whether a UID is a member of a named group. The check covers +/// both the primary group (parsed from `/etc/passwd` GID field) and +/// supplementary group memberships (parsed from `/etc/group`). +fn user_in_group(uid: u32, group_name: &str) -> bool { + if let Ok(passwd) = fs::read_to_string(passwd_file()) { + for line in passwd.lines() { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() < 4 { continue; } - if let Some((action, users)) = line.split_once('=') { - if action.trim() == action_id { - for user in users.split(',') { - if let Ok(u) = user.trim().parse::() { - if u == uid { return true; } + if let Ok(primary_gid) = parts[3].parse::() { + if primary_gid == uid { + if let Some(group_gid_str) = lookup_group_gid(group_name) { + if group_gid_str == primary_gid.to_string() { + return true; } } } } } } - // Default: deny for unknown actions from non-root users + + if let Ok(group_content) = fs::read_to_string(group_file()) { + for line in group_content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() < 4 { + continue; + } + if parts[0] != group_name { + continue; + } + for member in parts[3].split(',') { + let member = member.trim(); + if member.is_empty() { + continue; + } + if let Some(member_uid) = get_uid_from_username(member) { + if member_uid == uid { + return true; + } + } + } + } + } + + false +} + +/// Look up the GID of a named group from `/etc/group`. +fn lookup_group_gid(group_name: &str) -> Option { + let group_content = fs::read_to_string(group_file()).ok()?; + for line in group_content.lines() { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() < 3 { + continue; + } + if parts[0] == group_name { + return Some(parts[2].to_string()); + } + } + None +} + +/// Extract the UID from a polkit subject dictionary. The subject can be +/// either a `unix-user` (has `uid` directly) or a `unix-process` (has +/// `pid` + `uid`). Returns `None` if the subject is malformed (no UID). +fn extract_uid_from_subject(subject: &BorrowedSubject<'_>) -> Option { + if let Some(value) = subject.get("uid") { + if let Value::U32(uid) = value { + return Some(*uid); + } + if let Value::I32(uid) = value { + if *uid >= 0 { + return Some(*uid as u32); + } + } + } + None +} + +/// Evaluate a single user spec from the policy file. Returns `Some(true)` +/// if the spec explicitly allows, `Some(false)` if explicitly denies, +/// `None` if the spec does not match the user. +fn evaluate_user_spec(spec: &str, uid: u32) -> Option { + let spec = spec.trim(); + if spec.is_empty() { + return None; + } + + // Wildcard: match any user + if spec == "*" { + return Some(true); + } + + // Explicit deny (prefix `!`) + if let Some(rest) = spec.strip_prefix('!') { + let matched = match_user_spec(rest, uid); + return matched.map(|m| !m); + } + + match_user_spec(spec, uid) +} + +/// Match a non-deny user spec against the UID. Returns `Some(true)` if the +/// spec matches the user, `None` if it does not match (so the caller +/// should not consider this spec for decision-making). +fn match_user_spec(spec: &str, uid: u32) -> Option { + let spec = spec.trim(); + if spec.is_empty() { + return None; + } + + if let Some(group) = spec.strip_prefix('@') { + if group == "*" { + return Some(true); + } + if user_in_group(uid, group) { + return Some(true); + } + return None; + } + + if let Ok(u) = spec.parse::() { + return if u == uid { Some(true) } else { None }; + } + + None +} + +/// Walk the policy file looking for the action's user list. Returns +/// `Some(true)` if at least one user spec matched and authorized, `Some(false)` +/// if any explicit deny spec matched, `None` if no match was found. +fn lookup_action_decision(uid: u32, action_id: &str) -> Option { + let policy = fs::read_to_string(policy_file()).ok()?; + for line in policy.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((action, users)) = line.split_once('=') else { + continue; + }; + if action.trim() != action_id { + continue; + } + let mut explicit_deny = false; + let mut explicit_allow = false; + for user in users.split(',') { + if let Some(allow) = evaluate_user_spec(user, uid) { + if allow { + explicit_allow = true; + } else { + explicit_deny = true; + } + } + } + if explicit_deny { + return Some(false); + } + if explicit_allow { + return Some(true); + } + } + None +} + +/// Returns `true` if the UID is authorized for the action. UID 0 (root) +/// is always authorized. Other UIDs are checked against the policy file. +fn is_authorized(uid: u32, action_id: &str) -> bool { + if uid == 0 { + return true; + } + if let Some(decision) = lookup_action_decision(uid, action_id) { + return decision; + } false } @@ -67,8 +254,11 @@ async fn wait_for_dbus_socket() { let socket_path = env::var("DBUS_STARTER_ADDRESS") .ok() .and_then(|addr| addr.strip_prefix("unix:path=").map(String::from)) - .or_else(|| env::var("DBUS_SYSTEM_BUS_ADDRESS").ok() - .and_then(|addr| addr.strip_prefix("unix:path=").map(String::from))) + .or_else(|| { + env::var("DBUS_SYSTEM_BUS_ADDRESS") + .ok() + .and_then(|addr| addr.strip_prefix("unix:path=").map(String::from)) + }) .unwrap_or_else(|| "/run/dbus/system_bus_socket".to_string()); for _ in 0..30 { @@ -134,13 +324,19 @@ impl PolicyKitAuthority { #[zbus(name = "CheckAuthorization")] fn check_authorization( &self, + subject_kind: &str, + subject: BorrowedSubject<'_>, action_id: &str, - _details: AuthorizationDetails, + _details: BorrowedDetails<'_>, _flags: u32, _cancellation_id: &str, - ) -> (bool, bool, AuthorizationDetails) { - let authorized = is_authorized(0, action_id); - (authorized, !authorized, AuthorizationDetails::new()) + ) -> (bool, bool, Details) { + let uid = extract_uid_from_subject(&subject).unwrap_or(0); + let authorized = is_authorized(uid, action_id); + eprintln!( + "redbear-polkit: CheckAuthorization(subject_kind={subject_kind:?}, uid={uid}, action_id={action_id:?}) -> authorized={authorized}" + ); + (authorized, false, Details::new()) } #[zbus(name = "RegisterAuthenticationAgent")] @@ -172,7 +368,177 @@ impl PolicyKitAuthority { #[zbus(property(emits_changed_signal = "const"), name = "BackendVersion")] fn backend_version(&self) -> String { - String::from("0.1.0") + String::from("0.2.0") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Env vars are process-global, so serialize env-touching tests. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn test_paths() -> [&'static str; 3] { + [ + "/tmp/redbear-polkit-passwd-test", + "/tmp/redbear-polkit-group-test", + "/tmp/redbear-polkit-policy-test", + ] + } + + fn setup_env(passwd: &str, group: &str, policy: &str) { + let [passwd_path, group_path, policy_path] = test_paths(); + std::fs::write(passwd_path, passwd).unwrap(); + std::fs::write(group_path, group).unwrap(); + std::fs::write(policy_path, policy).unwrap(); + unsafe { + std::env::set_var("REDBEAR_POLKIT_PASSWD", passwd_path); + std::env::set_var("REDBEAR_POLKIT_GROUP", group_path); + std::env::set_var("REDBEAR_POLKIT_POLICY", policy_path); + } + } + + fn teardown_env() { + unsafe { + std::env::remove_var("REDBEAR_POLKIT_PASSWD"); + std::env::remove_var("REDBEAR_POLKIT_GROUP"); + std::env::remove_var("REDBEAR_POLKIT_POLICY"); + } + for p in test_paths() { + let _ = std::fs::remove_file(p); + } + } + + #[test] + fn passwd_lookup_finds_user() { + let _g = ENV_LOCK.lock().unwrap(); + setup_env( + "root:x:0:0:root:/root:/bin/sh\nuser1:x:1000:1000:user1:/home/user1:/bin/sh\n", + "", + "", + ); + assert_eq!(get_uid_from_username("root"), Some(0)); + assert_eq!(get_uid_from_username("user1"), Some(1000)); + assert_eq!(get_uid_from_username("nobody"), None); + teardown_env(); + } + + #[test] + fn user_in_group_via_passwd_primary_gid() { + let _g = ENV_LOCK.lock().unwrap(); + setup_env( + "user1:x:1000:500:user1:/home/user1:/bin/sh\n", + "wheel:x:500:root,user1\n", + "", + ); + assert!(user_in_group(1000, "wheel")); + assert!(!user_in_group(1000, "audio")); + teardown_env(); + } + + #[test] + fn user_in_group_via_supplementary() { + let _g = ENV_LOCK.lock().unwrap(); + setup_env( + "user1:x:1000:1000:user1:/home/user1:/bin/sh\n", + "audio:x:100:user1\n", + "", + ); + assert!(user_in_group(1000, "audio")); + teardown_env(); + } + + #[test] + fn root_is_always_authorized() { + let _g = ENV_LOCK.lock().unwrap(); + setup_env("", "", "org.example.action = 1000\n"); + assert!(is_authorized(0, "org.example.action")); + teardown_env(); + } + + #[test] + fn uid_in_policy_is_authorized() { + let _g = ENV_LOCK.lock().unwrap(); + setup_env("", "", "org.example.action = 0, 1000\n"); + assert!(is_authorized(1000, "org.example.action")); + assert!(!is_authorized(2000, "org.example.action")); + teardown_env(); + } + + #[test] + fn wildcard_in_policy_authorizes_all() { + let _g = ENV_LOCK.lock().unwrap(); + setup_env("", "", "org.example.action = *\n"); + assert!(is_authorized(1000, "org.example.action")); + assert!(is_authorized(65534, "org.example.action")); + teardown_env(); + } + + #[test] + fn group_membership_authorizes() { + let _g = ENV_LOCK.lock().unwrap(); + setup_env( + "user1:x:1000:1000:user1:/home/user1:/bin/sh\n", + "wheel:x:500:user1\n", + "org.example.action = @wheel\n", + ); + assert!(is_authorized(1000, "org.example.action")); + assert!(!is_authorized(2000, "org.example.action")); + teardown_env(); + } + + #[test] + fn explicit_deny_overrides_allow() { + let _g = ENV_LOCK.lock().unwrap(); + setup_env("", "", "org.example.action = *, !1000\n"); + assert!(is_authorized(2000, "org.example.action")); + assert!(!is_authorized(1000, "org.example.action")); + teardown_env(); + } + + #[test] + fn unknown_action_denies_by_default() { + let _g = ENV_LOCK.lock().unwrap(); + setup_env("", "", "org.other.action = 1000\n"); + assert!(!is_authorized(1000, "org.example.action")); + teardown_env(); + } + + #[test] + fn comment_lines_are_skipped() { + let _g = ENV_LOCK.lock().unwrap(); + setup_env( + "", + "", + "# This is a comment\norg.example.action = 1000\n", + ); + assert!(is_authorized(1000, "org.example.action")); + teardown_env(); + } + + #[test] + fn extract_uid_from_unix_user_subject() { + let mut subject = BorrowedSubject::new(); + subject.insert("uid".to_string(), Value::U32(1000)); + subject.insert("gid".to_string(), Value::U32(1000)); + assert_eq!(extract_uid_from_subject(&subject), Some(1000)); + } + + #[test] + fn extract_uid_from_unix_process_subject() { + let mut subject = BorrowedSubject::new(); + subject.insert("pid".to_string(), Value::U32(12345)); + subject.insert("uid".to_string(), Value::U32(1000)); + subject.insert("start-time".to_string(), Value::U64(1234567890)); + assert_eq!(extract_uid_from_subject(&subject), Some(1000)); + } + + #[test] + fn extract_uid_returns_none_when_missing() { + let subject = BorrowedSubject::new(); + assert_eq!(extract_uid_from_subject(&subject), None); } }