polkit: implement real authorization (v0.2 — fixes always-permit bug)
The previous redbear-polkit had a critical security flaw: the 'check_authorization' method ignored the 'subject' parameter and hardcoded 'uid=0' (root), making every authorization request succeed. Any caller could perform any action as 'root'. This was flagged as 5/5 security fragility in the DBUS assessment. This commit implements real authorization in the polkit daemon: * Subject UID extraction. The standard polkit signature is CheckAuthorization(subject_kind, subject, action_id, ...). The subject dict contains the caller UID (under 'uid'); we extract it and pass it to is_authorized. No more hardcoded root. * Comprehensive policy syntax. The policy file format now supports: - <uid> explicit UID - @<group> any user in the group (primary or supplementary) - * wildcard (allow any) - !<uid> explicit deny - !@<group> explicit deny for users in a group Multiple specs comma-separated, e.g. '@wheel, 1000, !@restricted'. * Default-deny for unknown actions. Previously the daemon returned 'true' for everything; now it returns 'false' for actions not in the policy file (unless the caller is root, which is always authorized). * match_user_spec returns None for non-match. The previous logic returned 'Some(false)' for a UID that didn't match the caller, which the policy combiner then treated as an explicit deny. The fix separates 'no match' (None) from 'explicit deny' (Some(false)) so multiple specifiers on one action combine correctly. * Env-var override for tests. REDBEAR_POLKIT_POLICY, REDBEAR_POLKIT_GROUP, REDBEAR_POLKIT_PASSWD env vars let tests point at /tmp/ files instead of /etc/. 13 unit tests cover the full decision matrix (root, uid, group, wildcard, deny, comment, unknown action, subject extraction). * Policy file staged in redbear-full.toml and redbear-mini.toml. The default /etc/polkit-1/policy.toml was missing entirely — redbear-polkit was running against a non-existent file, which meant default-deny for everything. The new policy.toml ships with concrete examples for power, storage, and network actions in the new comprehensive syntax. * BackendVersion bumped to 0.2.0 to reflect the contract change. DBUS-PLAN bumped to v3.2 (2026-07-26). §3.1 status table now lists redbear-polkit v0.2 as done. §14.3 reflects the actual state: 20 of 24 KF6 frameworks have USE_DBUS=ON; the remaining 4 are limited by daemon-binary or Qt-binding prerequisites (kwalletd, PolkitQt6-1, kded6, kglobalaccel), not by the flag itself. Also cleaned up: removed 5 stale stage service files from redbear-dbus-services/target/.../session-services/ that did not match the current source (the source's honest-absence pattern is now consistent with the build state). The cleanup is a local filesystem operation; the .gitignore already excludes that path. Tested: 13/13 unit tests pass on host (cargo test); binary builds clean (cargo build --bin redbear-polkit).
This commit is contained in:
@@ -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 = """
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, String>;
|
||||
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<String, Value<'static>>;
|
||||
type BorrowedSubject<'a> = HashMap<String, Value<'a>>;
|
||||
type BorrowedDetails<'a> = HashMap<String, Value<'a>>;
|
||||
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<u32> {
|
||||
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::<u32>() {
|
||||
if u == uid { return true; }
|
||||
if let Ok(primary_gid) = parts[3].parse::<u32>() {
|
||||
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<String> {
|
||||
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<u32> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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::<u32>() {
|
||||
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<bool> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user