Skip to main content

sysknife_daemon/
policy.rs

1//! Authorization policy for the daemon.
2//!
3//! Combines two checks:
4//! 1. **Per-action allowlist** — each known action name maps to a minimum
5//!    `CallerRole`. This is a compile-time constant so the daemon never
6//!    executes an action whose policy was not reviewed at build time.
7//!
8//! Approval freshness and one-time receipt consumption live in the transaction
9//! store so verification and the queued-to-running transition are atomic.
10//!
11//! Operators may raise the minimum role for individual actions via the
12//! `[policy.risk_overrides]` config section. See [`PolicyTable`].
13
14use std::collections::HashMap;
15
16use sysknife_types::{CallerRole, RiskLevel};
17
18use crate::auth::role_rank;
19
20// ---------------------------------------------------------------------------
21// Per-action minimum role
22// ---------------------------------------------------------------------------
23
24/// Return the minimum `CallerRole` required to call `action_name`, or `None`
25/// if the action is not recognised.
26///
27/// The mapping is intentionally exhaustive over every action known to the
28/// executor. Unknown actions return `None` so the caller can emit a
29/// validation-failure error rather than silently denying or allowing.
30pub fn min_role_for_action(action_name: &str) -> Option<CallerRole> {
31    let role = match action_name {
32        // ── Read-only / informational (Observer) ─────────────────────────
33        "GetSystemState"
34        | "CollectDiagnostics"
35        | "GetDeploymentHistory"
36        | "ListDeployments"
37        | "GetKernelArguments"
38        | "GetPendingUpdates"
39        | "ListFlatpakRemotes"
40        | "SearchFlatpakApps"
41        | "GetFlatpakAppInfo"
42        | "ListInstalledFlatpaks"
43        | "ListContainers"
44        | "GetContainerInfo"
45        | "GetLayeredPackages"
46        | "ListPackageRepositories"
47        | "ListServices"
48        | "GetServiceLogs"
49        | "GetServiceStatus"
50        | "GetServiceResourceLimits"
51        | "ListTimers"
52        | "ListToolboxes"
53        | "GetFirewallState"
54        | "GetNetworkStatus"
55        | "GetListeningPorts"
56        | "GetDiskUsage"
57        | "GetDateTime"
58        | "ListProcesses"
59        | "GetMemoryInfo"
60        // ── Observability / journald read-only ────────────────────────────
61        | "GetJournalLog"
62        // ── Storage / LVM read-only ───────────────────────────────────────
63        | "GetLvmReport"
64        // ── Kernel / sysctl read-only ─────────────────────────────────────
65        | "GetSysctl"
66        // ── Filesystem mounts read-only ───────────────────────────────────
67        | "GetMounts"
68        // ── Scoped sudoers.d read-only ────────────────────────────────────
69        | "GetSudoGrants"
70        // ── Log management read-only ──────────────────────────────────────
71        | "GetLogrotateStatus"
72        // ── PAM read-only ─────────────────────────────────────────────────
73        | "GetPasswordAging"
74        // ── auditd / certbot read-only ────────────────────────────────────
75        | "GetAuditRules"
76        | "GetCertificates"
77        | "GetAuthorizedKeys"
78        | "ListUsers"
79        | "ListGroups"
80        | "ListJobHistory"
81        // ── Ubuntu / apt read-only ────────────────────────────────────────
82        //
83        // AptUpdate refreshes the package index — no package is changed.
84        // Classified Observer to match its RiskLevel::Low preview profile.
85        | "AptUpdate"
86        | "AptSearch"
87        | "AptListInstalled"
88        | "AptShow"
89        | "AptListUpgradable"
90        | "AptHistoryList"
91        // ── Ubuntu / apt pinning read-only ────────────────────────────────
92        | "GetAptPins"
93        // ── Ubuntu / snap read-only ───────────────────────────────────────
94        | "SnapList"
95        | "SnapInfo"
96        // ── Ubuntu / ufw read-only ────────────────────────────────────────
97        | "UfwStatus"
98        // ── Ubuntu / distrobox read-only ──────────────────────────────────
99        | "DistroboxList"
100        // ── Ubuntu / netplan read-only ────────────────────────────────────
101        | "NetplanGetConfig"
102        // ── Ubuntu / grub read-only ───────────────────────────────────────
103        | "GrubGetKargs"
104        // ── Ubuntu / reboot status ────────────────────────────────────────
105        //
106        // CheckPendingReboot reads /var/run/reboot-required — no mutation.
107        | "CheckPendingReboot"
108        // ── Cross-distro / resolvectl read-only ───────────────────────────
109        | "ResolvectlStatus"
110        // ── Ubuntu / AppArmor read-only ───────────────────────────────────
111        | "AppArmorStatus"
112        // ── Ubuntu / cloud-init read-only ─────────────────────────────────
113        | "CloudInitStatus"
114        // ── Ubuntu / Flatpak read-only ────────────────────────────────────
115        | "UbuntuListFlatpaks"
116        // ── Ubuntu / fail2ban read-only ───────────────────────────────────
117        | "Fail2banStatus"
118        // ── Ubuntu Pro / Livepatch / Multipass read-only (Tier 3) ─────────
119        //
120        // ProStatus, LivepatchStatus, MultipassList are all read-only queries
121        // that require no subscription or elevated role.
122        | "ProStatus"
123        | "LivepatchStatus"
124        | "MultipassList" => CallerRole::Observer,
125
126        // ── Medium-risk mutations (Dev) ──────────────────────────────────
127        //
128        // Flatpak install/remove/update, container lifecycle, service
129        // control, toolbox ops, identity changes, network config,
130        // package repo management, and Ubuntu-specific mutating ops.
131        "InstallFlatpak"
132        | "RemoveFlatpak"
133        | "UpdateFlatpak"
134        | "AddFlatpakRemote"
135        | "RemoveFlatpakRemote"
136        | "CreateContainer"
137        | "StartContainer"
138        | "StopContainer"
139        | "RemoveContainer"
140        | "StartService"
141        | "StopService"
142        | "RestartService"
143        | "ReloadService"
144        | "ReloadDaemon"
145        | "SetServiceEnabled"
146        | "UnmaskService"
147        | "CreateToolbox"
148        | "RemoveToolbox"
149        | "SetHostname"
150        | "SetTimezone"
151        | "SetLocale"
152        | "SetNtp"
153        | "ConfigureWifi"
154        | "RemovePackageRepository"
155        | "EnablePackageRepository"
156        | "DisablePackageRepository"
157        // ── Ubuntu / apt medium-risk ──────────────────────────────────────
158        //
159        // AptAutoremove removes auto-installed leaf packages; it is a
160        // mutating operation despite its low risk level, so it requires Dev.
161        | "AptAutoremove"
162        | "AptInstall"
163        | "AptRemove"
164        | "AptPurge"
165        | "AptHold"
166        | "AptUnhold"
167        // ── Ubuntu / snap medium-risk ─────────────────────────────────────
168        | "SnapInstall"
169        | "SnapRemove"
170        | "SnapRefresh"
171        | "SnapHold"
172        | "SnapUnhold"
173        | "SnapRevert"
174        | "SnapClassicInstall"
175        // ── Ubuntu / distrobox medium-risk ────────────────────────────────
176        | "DistroboxCreate"
177        | "DistroboxRemove"
178        // ── Ubuntu / ppa medium-risk ──────────────────────────────────────
179        //
180        // PPA operations add or remove third-party apt sources; they mutate
181        // package provenance (supply-chain vector) but are reversible.
182        | "AddPpa"
183        | "RemovePpa"
184        // ── Ubuntu / apt pinning (reversible; steers version resolution) ──
185        | "SetAptPin"
186        | "RemoveAptPin"
187        // ── Log management (logrotate; reversible config) ─────────────────
188        | "ConfigureLogRotation"
189        | "RemoveLogRotation"
190        // ── Ubuntu / Flatpak medium-risk ───────────────────────────────────
191        | "UbuntuInstallFlatpak"
192        | "UbuntuRemoveFlatpak"
193        | "UbuntuUpdateFlatpak"
194        // ── Ubuntu / fail2ban medium-risk ──────────────────────────────────
195        //
196        // UnbanIp removes a ban; the address can be re-banned if needed.
197        | "Fail2banUnbanIp"
198        // ── Ubuntu / netplan medium-risk (Tier 3) ─────────────────────────
199        //
200        // NetplanGenerate regenerates backend config files without reloading
201        // interfaces — a dry-run / validation step that does not apply changes.
202        | "NetplanGenerate"
203        // ── Observability / journald maintenance ──────────────────────────
204        //
205        // VacuumJournal deletes old journal files to reclaim disk. It cannot
206        // affect running services and only ages out historical logs, so it is
207        // Dev, not Admin.
208        | "VacuumJournal" => CallerRole::Dev,
209
210        // ── High-risk system mutations (Admin) ───────────────────────────
211        //
212        // Deployment lifecycle, layering, kernel arguments, privilege-
213        // escalation-sensitive user-group and SSH-key operations, account
214        // deletion (irreversible), and security-critical network/service ops:
215        //   CreateUser       — T1136.001 Persistence; NIST AC-2
216        //   ConfigureFirewall — T1562.004 Defense Evasion; NIST SC-7
217        //   MaskService      — T1562.001 Impair Defenses; irreversible
218        //   AddPackageRepository — supply-chain vector; NIST SI-7/CM-7
219        //   SetDnsServers    — DNS hijacking / MitM (T1557); NIST SC-7
220        "UpdateSystem"
221        | "PinDeployment"
222        | "UnpinDeployment"
223        | "RebaseSystem"
224        | "CleanupDeployments"
225        | "RebootSystem"
226        | "RollbackDeployment"
227        | "SetKernelArguments"
228        | "InstallPackages"
229        | "RemovePackages"
230        | "AddLayeredPackage"
231        | "RemoveLayeredPackage"
232        | "ReplaceLayeredPackage"
233        | "ResetLayeredPackageOverride"
234        | "RemoveBasePackage"
235        | "AddUserToGroup"
236        | "RemoveUserFromGroup"
237        | "CreateGroup"
238        | "DeleteGroup"
239        | "LockUserAccount"
240        | "UnlockUserAccount"
241        | "SignalProcess"
242        // SetSshdOption can lock out remote access; ConfigureUnattendedUpgrades
243        // changes the system's automatic-update posture. Both are Admin/High.
244        | "SetSshdOption"
245        | "ConfigureUnattendedUpgrades"
246        // CreateScheduledJob installs a persistent root-scheduled systemd timer.
247        | "CreateScheduledJob"
248        // ── Storage / LVM mutations ───────────────────────────────────────
249        //
250        // Growing, creating, or snapshotting a logical volume rewrites storage
251        // metadata; a bad size or target can consume a VG or (for extend)
252        // resize a mounted filesystem. All Admin/High.
253        | "ExtendLogicalVolume"
254        | "CreateLogicalVolume"
255        | "CreateLvSnapshot"
256        // ── Kernel / sysctl mutation ──────────────────────────────────────
257        //
258        // SetSysctl changes a live kernel parameter and persists it. A wrong
259        // net.* / vm.* / kernel.* value can degrade networking, memory
260        // behaviour, or security posture, so it is Admin/High.
261        | "SetSysctl"
262        // SetServiceResourceLimits caps a service's memory/CPU/tasks via
263        // set-property; too tight a MemoryMax can OOM-kill the service, so it
264        // is Admin/High.
265        | "SetServiceResourceLimits"
266        // ── Filesystem mounts / swap mutations ────────────────────────────
267        //
268        // Mount/unmount and swap ops rewrite /etc/fstab and change what is
269        // mounted; a wrong device or mountpoint risks data/availability. All
270        // Admin/High (the helper forces `nofail` so a bad entry can't wedge
271        // boot, but the operation itself is still privileged).
272        | "AddMount"
273        | "RemoveMount"
274        | "AddSwap"
275        | "RemoveSwap"
276        // ── Scoped sudoers.d mutations ────────────────────────────────────
277        //
278        // Grant/RevokeSudoAccess configure privilege escalation itself — the
279        // highest-consequence mutating family. Admin/High (the helper's
280        // visudo -cf gate prevents a syntactically broken drop-in, but the
281        // grant is still a real privilege change).
282        | "GrantSudoAccess"
283        | "RevokeSudoAccess"
284        // ── Remote syslog forwarding (log-exfil surface) ──────────────────
285        //
286        // ConfigureRemoteSyslog sends all logs to an external collector — a
287        // data-exfiltration vector — so it is Admin/High.
288        | "ConfigureRemoteSyslog"
289        | "RemoveRemoteSyslog"
290        // ── PAM password policy (auth-hardening; lockout can deny logins) ─
291        | "SetPasswordAging"
292        | "SetPasswordPolicy"
293        | "SetAccountLockout"
294        // ── auditd rules / certbot / fail2ban jail config ─────────────────
295        | "AddAuditRule"
296        | "RemoveAuditRule"
297        | "ObtainCertificate"
298        | "RenewCertificates"
299        | "ConfigureFail2banJail"
300        | "DeleteUser"
301        | "AddAuthorizedKey"
302        | "RemoveAuthorizedKey"
303        | "CreateUser"
304        | "ConfigureFirewall"
305        | "MaskService"
306        | "AddPackageRepository"
307        | "SetDnsServers"
308        // ResolvectlSetDns runs the same interface DNS change as SetDnsServers
309        // (DNS hijacking / MitM — T1557; NIST SC-7). Per-interface scope is not
310        // a mitigation, so it is Admin/High, not Dev.
311        | "ResolvectlSetDns"
312        // ── Ubuntu / apt high-risk ────────────────────────────────────────
313        //
314        // AptUpdate: low-risk (Observer tier, listed above).
315        // AptAutoremove: low-risk (Observer tier, listed above).
316        // AptUpgrade: High — may remove packages (dist-upgrade) + service restarts.
317        | "AptUpgrade"
318        // ── Ubuntu / ufw high-risk ────────────────────────────────────────
319        //
320        // All ufw mutating ops are Admin — misconfigured firewall rules can
321        // lock out SSH access (T1562.004 Defense Evasion; NIST SC-7).
322        | "UfwEnable"
323        | "UfwDisable"
324        | "UfwAllow"
325        | "UfwDeny"
326        | "UfwReset"
327        // ── Ubuntu / netplan high-risk ────────────────────────────────────
328        //
329        // NetplanApply reconfigures network interfaces immediately and can
330        // disconnect SSH sessions (NIST SC-7; operational continuity risk).
331        | "NetplanApply"
332        // ── Ubuntu / grub high-risk ───────────────────────────────────────
333        //
334        // GrubSetKargs modifies the kernel command line and regenerates GRUB
335        // config. Incorrect arguments can prevent the system from booting.
336        // Requires reboot to take effect.
337        | "GrubSetKargs"
338        // ── Ubuntu / AppArmor high-risk ───────────────────────────────────
339        //
340        // AppArmorEnforce activates enforcement. Blocking violations that the
341        // application relies on can immediately cause it to fail or lose data.
342        // AppArmorComplain is the inverse: it stops a profile from *blocking*
343        // violations (learning mode), disabling MAC enforcement for that
344        // profile — a defense-evasion lever. Both directions alter enforcement
345        // of a security control (T1562.001 Impair Defenses), so both are Admin.
346        | "AppArmorEnforce"
347        | "AppArmorComplain"
348        // ── Ubuntu / fail2ban high-risk ───────────────────────────────────
349        //
350        // Fail2banBanIp immediately blocks an IP for all traffic passing
351        // through the named jail. Banning the admin's own IP on sshd severs
352        // remote access. (T1562.004 Defense Evasion; NIST SC-7.)
353        | "Fail2banBanIp"
354        // ── Ubuntu / ufw Tier 3 high-risk ────────────────────────────────
355        //
356        // UfwDeleteRule removes a numbered rule — misconfigured deletion can
357        // expose services or drop needed traffic (T1562.004 Defense Evasion).
358        // UfwLimit adds rate-limiting; can inadvertently block legitimate
359        // traffic under high-connection workloads — same lock-out risk as
360        // other ufw mutations.
361        | "UfwDeleteRule"
362        | "UfwLimit"
363        // ── Ubuntu / netplan Tier 3 high-risk ────────────────────────────
364        //
365        // NetplanSet mutates the netplan config in-memory; misconfigurations
366        // applied via NetplanApply can disconnect SSH sessions (NIST SC-7).
367        | "NetplanSet"
368        // ── Ubuntu Pro Tier 3 high-risk ───────────────────────────────────
369        //
370        // ProAttach binds the machine to a subscription contract (T1078.003
371        // Valid Accounts — contract modification). ProDetach removes it.
372        | "ProAttach"
373        | "ProDetach"
374        | "EnableProService"
375        | "DisableProService"
376        // ── Ubuntu release upgrade Tier 3 high-risk ───────────────────────
377        //
378        // UbuntuReleaseUpgrade upgrades the entire OS to the next release.
379        // Takes 20–45 minutes, requires reboot, and is effectively irreversible
380        // without a full reinstall (High; Admin gating).
381        | "UbuntuReleaseUpgrade" => CallerRole::Admin,
382
383        _ => return None,
384    };
385    Some(role)
386}
387
388/// Check whether `caller` is authorized to invoke `action_name` against the
389/// **compile-time baseline** (no operator overrides).
390///
391/// Returns `true` if the action is known and the caller's role meets or
392/// exceeds the minimum role required by the per-action allowlist. Returns
393/// `false` for unknown actions (the caller should surface a validation error
394/// separately).
395///
396/// Production code paths should go through [`PolicyTable::action_allowed`]
397/// to honour `[policy.risk_overrides]`. This free function is kept for tests
398/// and call sites that genuinely need the baseline (e.g. validating that an
399/// override is a *raise*).
400pub fn action_allowed(caller: &CallerRole, action_name: &str) -> bool {
401    match min_role_for_action(action_name) {
402        Some(required) => role_rank(caller) >= role_rank(&required),
403        None => false,
404    }
405}
406
407// ---------------------------------------------------------------------------
408// Operator overrides — `[policy.risk_overrides]`
409// ---------------------------------------------------------------------------
410
411/// Per-action authorization policy with optional operator overrides.
412///
413/// Wraps the compile-time baseline ([`min_role_for_action`]) with a map of
414/// per-action overrides loaded from `[policy.risk_overrides]` in
415/// `~/.config/sysknife/config.toml`.
416///
417/// **Security invariant: overrides may only raise the minimum role.** A
418/// downgrade attempt is rejected at construction time so a misconfigured —
419/// or maliciously modified — config can never silently grant escalated access.
420#[derive(Clone, Debug, Default)]
421pub struct PolicyTable {
422    /// Action name → effective minimum `CallerRole`. Always >= the baseline.
423    overrides: HashMap<String, CallerRole>,
424}
425
426/// Errors that can occur while validating `[policy.risk_overrides]` at startup.
427#[derive(Debug, thiserror::Error, PartialEq, Eq)]
428pub enum PolicyValidationError {
429    #[error("unknown action in [policy.risk_overrides]: {action}")]
430    UnknownAction { action: String },
431
432    #[error(
433        "invalid risk level {value:?} for action {action}: \
434         must be \"Low\", \"Medium\", or \"High\""
435    )]
436    InvalidRiskLevel { action: String, value: String },
437
438    #[error(
439        "policy override for {action} would lower the minimum role from \
440         {baseline:?} to {attempted:?}; overrides may only raise"
441    )]
442    CannotLower {
443        action: String,
444        baseline: CallerRole,
445        attempted: CallerRole,
446    },
447}
448
449impl PolicyTable {
450    /// Construct an empty policy table — no overrides, identical behaviour
451    /// to the compile-time baseline.
452    pub fn empty() -> Self {
453        Self {
454            overrides: HashMap::new(),
455        }
456    }
457
458    /// Construct a policy table from a `risk_overrides` map.
459    ///
460    /// Validates each entry:
461    /// - the action name must be known to [`min_role_for_action`];
462    /// - the risk level must parse to `Low`/`Medium`/`High`;
463    /// - the resulting role must be **at or above** the compile-time baseline.
464    ///
465    /// On any violation, returns the first error encountered (callers should
466    /// surface it as a fatal startup error so operator typos are loud).
467    pub fn from_overrides(raw: &HashMap<String, String>) -> Result<Self, PolicyValidationError> {
468        let mut overrides = HashMap::with_capacity(raw.len());
469        for (action, level_str) in raw {
470            let baseline = min_role_for_action(action).ok_or_else(|| {
471                PolicyValidationError::UnknownAction {
472                    action: action.clone(),
473                }
474            })?;
475
476            let level = parse_risk_level(level_str).ok_or_else(|| {
477                PolicyValidationError::InvalidRiskLevel {
478                    action: action.clone(),
479                    value: level_str.clone(),
480                }
481            })?;
482
483            let attempted = role_for_risk_level(level);
484
485            if role_rank(&attempted) < role_rank(&baseline) {
486                return Err(PolicyValidationError::CannotLower {
487                    action: action.clone(),
488                    baseline,
489                    attempted,
490                });
491            }
492
493            overrides.insert(action.clone(), attempted);
494        }
495        Ok(Self { overrides })
496    }
497
498    /// Effective minimum `CallerRole` for `action_name`, accounting for
499    /// overrides. Returns `None` for unknown actions.
500    pub fn min_role_for_action(&self, action_name: &str) -> Option<CallerRole> {
501        let baseline = min_role_for_action(action_name)?;
502        // Override-or-baseline; the constructor guarantees overrides never lower.
503        Some(self.overrides.get(action_name).copied().unwrap_or(baseline))
504    }
505
506    /// Whether `caller` is authorized to invoke `action_name` under this
507    /// table. Returns `false` for unknown actions.
508    pub fn action_allowed(&self, caller: &CallerRole, action_name: &str) -> bool {
509        match self.min_role_for_action(action_name) {
510            Some(required) => role_rank(caller) >= role_rank(&required),
511            None => false,
512        }
513    }
514
515    /// Active overrides, sorted by action name. Suitable for an INFO-level
516    /// startup log.
517    pub fn active_overrides(&self) -> Vec<(&str, CallerRole)> {
518        let mut entries: Vec<(&str, CallerRole)> = self
519            .overrides
520            .iter()
521            .map(|(k, v)| (k.as_str(), *v))
522            .collect();
523        entries.sort_by_key(|(k, _)| *k);
524        entries
525    }
526
527    /// Number of active overrides.
528    pub fn override_count(&self) -> usize {
529        self.overrides.len()
530    }
531}
532
533/// Map a [`RiskLevel`] to the [`CallerRole`] it requires under the baseline.
534///
535/// `Low` → `Observer`, `Medium` → `Dev`, `High` → `Admin`. This mirrors the
536/// tiers in [`min_role_for_action`].
537fn role_for_risk_level(level: RiskLevel) -> CallerRole {
538    match level {
539        RiskLevel::Low => CallerRole::Observer,
540        RiskLevel::Medium => CallerRole::Dev,
541        RiskLevel::High => CallerRole::Admin,
542    }
543}
544
545/// Case-insensitive parse of `"Low"` / `"Medium"` / `"High"` into [`RiskLevel`].
546fn parse_risk_level(s: &str) -> Option<RiskLevel> {
547    match s.trim().to_ascii_lowercase().as_str() {
548        "low" => Some(RiskLevel::Low),
549        "medium" => Some(RiskLevel::Medium),
550        "high" => Some(RiskLevel::High),
551        _ => None,
552    }
553}
554
555// ---------------------------------------------------------------------------
556// Tests
557// ---------------------------------------------------------------------------
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    // ------------------------------------------------------------------
564    // Observer — can call read-only actions
565    // ------------------------------------------------------------------
566
567    #[test]
568    fn observer_can_call_read_only_actions() {
569        let role = CallerRole::Observer;
570        assert!(action_allowed(&role, "GetSystemState"));
571        assert!(action_allowed(&role, "CollectDiagnostics"));
572        assert!(action_allowed(&role, "GetDeploymentHistory"));
573        assert!(action_allowed(&role, "ListDeployments"));
574        assert!(action_allowed(&role, "GetKernelArguments"));
575        assert!(action_allowed(&role, "ListFlatpakRemotes"));
576        assert!(action_allowed(&role, "SearchFlatpakApps"));
577        assert!(action_allowed(&role, "GetFlatpakAppInfo"));
578        assert!(action_allowed(&role, "ListContainers"));
579        assert!(action_allowed(&role, "GetContainerInfo"));
580        assert!(action_allowed(&role, "GetLayeredPackages"));
581        assert!(action_allowed(&role, "ListPackageRepositories"));
582        assert!(action_allowed(&role, "ListServices"));
583        assert!(action_allowed(&role, "GetServiceLogs"));
584        assert!(action_allowed(&role, "ListToolboxes"));
585        assert!(action_allowed(&role, "GetFirewallState"));
586        assert!(action_allowed(&role, "ListUsers"));
587        assert!(action_allowed(&role, "ListGroups"));
588        assert!(action_allowed(&role, "GetDiskUsage"));
589        assert!(action_allowed(&role, "ListProcesses"));
590        assert!(action_allowed(&role, "GetMemoryInfo"));
591        assert!(action_allowed(&role, "GetNetworkStatus"));
592        assert!(action_allowed(&role, "GetAuthorizedKeys"));
593        assert!(action_allowed(&role, "ListJobHistory"));
594    }
595
596    // ------------------------------------------------------------------
597    // Observer — cannot call medium or high risk actions
598    // ------------------------------------------------------------------
599
600    #[test]
601    fn observer_cannot_call_medium_or_high_risk_actions() {
602        let role = CallerRole::Observer;
603        // Medium-risk
604        assert!(!action_allowed(&role, "InstallFlatpak"));
605        assert!(!action_allowed(&role, "RemoveFlatpak"));
606        assert!(!action_allowed(&role, "CreateContainer"));
607        assert!(!action_allowed(&role, "StartService"));
608        assert!(!action_allowed(&role, "CreateToolbox"));
609        assert!(!action_allowed(&role, "SetHostname"));
610        assert!(!action_allowed(&role, "ConfigureWifi"));
611        assert!(!action_allowed(&role, "AddPackageRepository"));
612        // High-risk
613        assert!(!action_allowed(&role, "UpdateSystem"));
614        assert!(!action_allowed(&role, "RebaseSystem"));
615        assert!(!action_allowed(&role, "InstallPackages"));
616        assert!(!action_allowed(&role, "AddUserToGroup"));
617        assert!(!action_allowed(&role, "RebootSystem"));
618        assert!(!action_allowed(&role, "SetKernelArguments"));
619    }
620
621    // ------------------------------------------------------------------
622    // Dev — can call medium risk actions (and all observer actions)
623    // ------------------------------------------------------------------
624
625    #[test]
626    fn dev_can_call_medium_risk_actions() {
627        let role = CallerRole::Dev;
628        // Medium-risk
629        assert!(action_allowed(&role, "InstallFlatpak"));
630        assert!(action_allowed(&role, "RemoveFlatpak"));
631        assert!(action_allowed(&role, "UpdateFlatpak"));
632        assert!(action_allowed(&role, "AddFlatpakRemote"));
633        assert!(action_allowed(&role, "RemoveFlatpakRemote"));
634        assert!(action_allowed(&role, "CreateContainer"));
635        assert!(action_allowed(&role, "StartContainer"));
636        assert!(action_allowed(&role, "StopContainer"));
637        assert!(action_allowed(&role, "RemoveContainer"));
638        assert!(action_allowed(&role, "StartService"));
639        assert!(action_allowed(&role, "StopService"));
640        assert!(action_allowed(&role, "RestartService"));
641        assert!(action_allowed(&role, "ReloadService"));
642        assert!(action_allowed(&role, "ReloadDaemon"));
643        assert!(action_allowed(&role, "SetServiceEnabled"));
644        assert!(action_allowed(&role, "UnmaskService"));
645        assert!(action_allowed(&role, "CreateToolbox"));
646        assert!(action_allowed(&role, "RemoveToolbox"));
647        assert!(action_allowed(&role, "SetHostname"));
648        assert!(action_allowed(&role, "SetTimezone"));
649        assert!(action_allowed(&role, "SetLocale"));
650        assert!(action_allowed(&role, "SetNtp"));
651        assert!(action_allowed(&role, "ConfigureWifi"));
652        assert!(action_allowed(&role, "RemovePackageRepository"));
653        assert!(action_allowed(&role, "EnablePackageRepository"));
654        assert!(action_allowed(&role, "DisablePackageRepository"));
655        // Observer-level actions still allowed
656        assert!(action_allowed(&role, "GetSystemState"));
657        assert!(action_allowed(&role, "ListServices"));
658        assert!(action_allowed(&role, "ListContainers"));
659        assert!(action_allowed(&role, "ListInstalledFlatpaks"));
660        assert!(action_allowed(&role, "GetPendingUpdates"));
661        assert!(action_allowed(&role, "GetServiceStatus"));
662        assert!(action_allowed(&role, "ListTimers"));
663    }
664
665    // ------------------------------------------------------------------
666    // Dev — cannot call high risk actions
667    // ------------------------------------------------------------------
668
669    #[test]
670    fn dev_cannot_call_high_risk_actions() {
671        let role = CallerRole::Dev;
672        assert!(!action_allowed(&role, "UpdateSystem"));
673        assert!(!action_allowed(&role, "PinDeployment"));
674        assert!(!action_allowed(&role, "UnpinDeployment"));
675        assert!(!action_allowed(&role, "RebaseSystem"));
676        assert!(!action_allowed(&role, "CleanupDeployments"));
677        assert!(!action_allowed(&role, "RebootSystem"));
678        assert!(!action_allowed(&role, "RollbackDeployment"));
679        assert!(!action_allowed(&role, "SetKernelArguments"));
680        assert!(!action_allowed(&role, "InstallPackages"));
681        assert!(!action_allowed(&role, "RemovePackages"));
682        assert!(!action_allowed(&role, "AddLayeredPackage"));
683        assert!(!action_allowed(&role, "RemoveLayeredPackage"));
684        assert!(!action_allowed(&role, "ReplaceLayeredPackage"));
685        assert!(!action_allowed(&role, "ResetLayeredPackageOverride"));
686        assert!(!action_allowed(&role, "RemoveBasePackage"));
687        assert!(!action_allowed(&role, "AddUserToGroup"));
688        assert!(!action_allowed(&role, "RemoveUserFromGroup"));
689        // Access-control operations require Admin
690        assert!(!action_allowed(&role, "DeleteUser"));
691        assert!(!action_allowed(&role, "AddAuthorizedKey"));
692        assert!(!action_allowed(&role, "RemoveAuthorizedKey"));
693        // Security-critical ops reclassified to Admin (NIST 800-53 / CIS v8.1)
694        assert!(!action_allowed(&role, "CreateUser"));
695        assert!(!action_allowed(&role, "ConfigureFirewall"));
696        assert!(!action_allowed(&role, "MaskService"));
697        assert!(!action_allowed(&role, "AddPackageRepository"));
698        assert!(!action_allowed(&role, "SetDnsServers"));
699    }
700
701    // ------------------------------------------------------------------
702    // Admin — can call high risk actions (and all lower)
703    // ------------------------------------------------------------------
704
705    #[test]
706    fn admin_can_call_high_risk_actions() {
707        let role = CallerRole::Admin;
708        // High-risk
709        assert!(action_allowed(&role, "UpdateSystem"));
710        assert!(action_allowed(&role, "PinDeployment"));
711        assert!(action_allowed(&role, "UnpinDeployment"));
712        assert!(action_allowed(&role, "RebaseSystem"));
713        assert!(action_allowed(&role, "CleanupDeployments"));
714        assert!(action_allowed(&role, "RebootSystem"));
715        assert!(action_allowed(&role, "RollbackDeployment"));
716        assert!(action_allowed(&role, "SetKernelArguments"));
717        assert!(action_allowed(&role, "InstallPackages"));
718        assert!(action_allowed(&role, "RemovePackages"));
719        assert!(action_allowed(&role, "AddLayeredPackage"));
720        assert!(action_allowed(&role, "RemoveLayeredPackage"));
721        assert!(action_allowed(&role, "ReplaceLayeredPackage"));
722        assert!(action_allowed(&role, "ResetLayeredPackageOverride"));
723        assert!(action_allowed(&role, "RemoveBasePackage"));
724        assert!(action_allowed(&role, "AddUserToGroup"));
725        assert!(action_allowed(&role, "RemoveUserFromGroup"));
726        assert!(action_allowed(&role, "DeleteUser"));
727        assert!(action_allowed(&role, "AddAuthorizedKey"));
728        assert!(action_allowed(&role, "RemoveAuthorizedKey"));
729        // Security-critical ops reclassified to Admin
730        assert!(action_allowed(&role, "CreateUser"));
731        assert!(action_allowed(&role, "ConfigureFirewall"));
732        assert!(action_allowed(&role, "MaskService"));
733        assert!(action_allowed(&role, "AddPackageRepository"));
734        assert!(action_allowed(&role, "SetDnsServers"));
735        // Medium-risk still allowed
736        assert!(action_allowed(&role, "InstallFlatpak"));
737        assert!(action_allowed(&role, "UpdateFlatpak"));
738        assert!(action_allowed(&role, "CreateToolbox"));
739        assert!(action_allowed(&role, "StartService"));
740        assert!(action_allowed(&role, "ReloadService"));
741        assert!(action_allowed(&role, "ReloadDaemon"));
742        // Observer-level still allowed
743        assert!(action_allowed(&role, "GetSystemState"));
744        assert!(action_allowed(&role, "ListUsers"));
745        assert!(action_allowed(&role, "ListInstalledFlatpaks"));
746        assert!(action_allowed(&role, "GetPendingUpdates"));
747        assert!(action_allowed(&role, "GetServiceStatus"));
748        assert!(action_allowed(&role, "ListTimers"));
749    }
750
751    // ------------------------------------------------------------------
752    // Boot — can call everything
753    // ------------------------------------------------------------------
754
755    #[test]
756    fn boot_can_call_everything() {
757        let role = CallerRole::Boot;
758        // Sample from each tier
759        assert!(action_allowed(&role, "GetSystemState"));
760        assert!(action_allowed(&role, "ListDeployments"));
761        assert!(action_allowed(&role, "ListContainers"));
762        assert!(action_allowed(&role, "GetFirewallState"));
763        assert!(action_allowed(&role, "ListInstalledFlatpaks"));
764        assert!(action_allowed(&role, "GetPendingUpdates"));
765        assert!(action_allowed(&role, "GetServiceStatus"));
766        assert!(action_allowed(&role, "ListTimers"));
767        assert!(action_allowed(&role, "InstallFlatpak"));
768        assert!(action_allowed(&role, "UpdateFlatpak"));
769        assert!(action_allowed(&role, "CreateToolbox"));
770        assert!(action_allowed(&role, "StartService"));
771        assert!(action_allowed(&role, "ReloadService"));
772        assert!(action_allowed(&role, "ReloadDaemon"));
773        assert!(action_allowed(&role, "SetHostname"));
774        assert!(action_allowed(&role, "ConfigureWifi"));
775        assert!(action_allowed(&role, "CreateUser"));
776        assert!(action_allowed(&role, "UpdateSystem"));
777        assert!(action_allowed(&role, "RebaseSystem"));
778        assert!(action_allowed(&role, "RebootSystem"));
779        assert!(action_allowed(&role, "InstallPackages"));
780        assert!(action_allowed(&role, "RemoveBasePackage"));
781        assert!(action_allowed(&role, "AddUserToGroup"));
782        assert!(action_allowed(&role, "RemoveUserFromGroup"));
783        assert!(action_allowed(&role, "DeleteUser"));
784        assert!(action_allowed(&role, "AddAuthorizedKey"));
785        assert!(action_allowed(&role, "RemoveAuthorizedKey"));
786    }
787
788    // ------------------------------------------------------------------
789    // Security reclassification: five actions require Admin, not Dev
790    //
791    // Rationale (NIST 800-53 / CIS Controls v8.1 / MITRE ATT&CK):
792    //   CreateUser       — T1136.001 Persistence; NIST AC-2 High-baseline
793    //   ConfigureFirewall — T1562.004 Defense Evasion; NIST SC-7 Moderate+
794    //   MaskService      — T1562.001 Impair Defenses; permanent/irreversible
795    //   AddPackageRepository — supply chain vector; NIST SI-7/CM-7 Moderate+
796    //   SetDnsServers    — DNS hijacking / MitM (T1557 path); NIST SC-7
797    // ------------------------------------------------------------------
798
799    #[test]
800    fn dev_cannot_create_user() {
801        assert!(!action_allowed(&CallerRole::Dev, "CreateUser"));
802    }
803
804    #[test]
805    fn dev_cannot_configure_firewall() {
806        assert!(!action_allowed(&CallerRole::Dev, "ConfigureFirewall"));
807    }
808
809    #[test]
810    fn dev_cannot_mask_service() {
811        assert!(!action_allowed(&CallerRole::Dev, "MaskService"));
812    }
813
814    #[test]
815    fn dev_cannot_add_package_repository() {
816        assert!(!action_allowed(&CallerRole::Dev, "AddPackageRepository"));
817    }
818
819    #[test]
820    fn dev_cannot_set_dns_servers() {
821        assert!(!action_allowed(&CallerRole::Dev, "SetDnsServers"));
822    }
823
824    #[test]
825    fn admin_can_create_user() {
826        assert!(action_allowed(&CallerRole::Admin, "CreateUser"));
827    }
828
829    #[test]
830    fn admin_can_configure_firewall() {
831        assert!(action_allowed(&CallerRole::Admin, "ConfigureFirewall"));
832    }
833
834    #[test]
835    fn admin_can_mask_service() {
836        assert!(action_allowed(&CallerRole::Admin, "MaskService"));
837    }
838
839    #[test]
840    fn admin_can_add_package_repository() {
841        assert!(action_allowed(&CallerRole::Admin, "AddPackageRepository"));
842    }
843
844    #[test]
845    fn admin_can_set_dns_servers() {
846        assert!(action_allowed(&CallerRole::Admin, "SetDnsServers"));
847    }
848
849    // ------------------------------------------------------------------
850    // Unknown actions are denied
851    // ------------------------------------------------------------------
852
853    #[test]
854    fn unknown_action_denied_for_all_roles() {
855        assert!(!action_allowed(&CallerRole::Observer, "NonExistent"));
856        assert!(!action_allowed(&CallerRole::Dev, "NonExistent"));
857        assert!(!action_allowed(&CallerRole::Admin, "NonExistent"));
858        assert!(!action_allowed(&CallerRole::Boot, "NonExistent"));
859    }
860
861    // ------------------------------------------------------------------
862    // PolicyTable — operator overrides
863    // ------------------------------------------------------------------
864
865    #[test]
866    fn empty_policy_table_matches_baseline() {
867        let table = PolicyTable::empty();
868        assert_eq!(
869            table.min_role_for_action("InstallFlatpak"),
870            Some(CallerRole::Dev)
871        );
872        assert_eq!(
873            table.min_role_for_action("UpdateSystem"),
874            Some(CallerRole::Admin)
875        );
876        assert!(table.action_allowed(&CallerRole::Dev, "InstallFlatpak"));
877        assert!(!table.action_allowed(&CallerRole::Dev, "UpdateSystem"));
878        assert_eq!(table.override_count(), 0);
879    }
880
881    #[test]
882    fn override_raises_minimum_role() {
883        let mut raw = HashMap::new();
884        raw.insert("InstallFlatpak".to_string(), "High".to_string());
885        let table = PolicyTable::from_overrides(&raw).expect("valid raise");
886
887        assert_eq!(
888            table.min_role_for_action("InstallFlatpak"),
889            Some(CallerRole::Admin)
890        );
891        // Dev was previously allowed; now needs Admin.
892        assert!(!table.action_allowed(&CallerRole::Dev, "InstallFlatpak"));
893        assert!(table.action_allowed(&CallerRole::Admin, "InstallFlatpak"));
894        // Other actions unchanged.
895        assert_eq!(
896            table.min_role_for_action("GetSystemState"),
897            Some(CallerRole::Observer)
898        );
899    }
900
901    #[test]
902    fn override_at_baseline_is_noop() {
903        // GetSystemState baseline = Low (Observer). Setting "Low" is a no-op raise.
904        let mut raw = HashMap::new();
905        raw.insert("GetSystemState".to_string(), "Low".to_string());
906        let table = PolicyTable::from_overrides(&raw).expect("same-level override");
907        assert_eq!(
908            table.min_role_for_action("GetSystemState"),
909            Some(CallerRole::Observer)
910        );
911    }
912
913    #[test]
914    fn override_below_baseline_is_rejected() {
915        // UpdateSystem baseline = High (Admin). Trying to lower to Medium (Dev) must fail.
916        let mut raw = HashMap::new();
917        raw.insert("UpdateSystem".to_string(), "Medium".to_string());
918        let err = PolicyTable::from_overrides(&raw).expect_err("downgrade rejected");
919        match err {
920            PolicyValidationError::CannotLower {
921                action,
922                baseline,
923                attempted,
924            } => {
925                assert_eq!(action, "UpdateSystem");
926                assert_eq!(baseline, CallerRole::Admin);
927                assert_eq!(attempted, CallerRole::Dev);
928            }
929            other => panic!("wrong error variant: {other:?}"),
930        }
931    }
932
933    #[test]
934    fn override_unknown_action_is_rejected() {
935        let mut raw = HashMap::new();
936        raw.insert("DefinitelyNotAnAction".to_string(), "High".to_string());
937        let err = PolicyTable::from_overrides(&raw).expect_err("unknown action rejected");
938        match err {
939            PolicyValidationError::UnknownAction { action } => {
940                assert_eq!(action, "DefinitelyNotAnAction");
941            }
942            other => panic!("wrong error variant: {other:?}"),
943        }
944    }
945
946    #[test]
947    fn invalid_risk_level_is_rejected() {
948        let mut raw = HashMap::new();
949        raw.insert("InstallFlatpak".to_string(), "Critical".to_string());
950        let err = PolicyTable::from_overrides(&raw).expect_err("invalid level rejected");
951        match err {
952            PolicyValidationError::InvalidRiskLevel { action, value } => {
953                assert_eq!(action, "InstallFlatpak");
954                assert_eq!(value, "Critical");
955            }
956            other => panic!("wrong error variant: {other:?}"),
957        }
958    }
959
960    #[test]
961    fn risk_level_parsing_is_case_insensitive() {
962        let mut raw = HashMap::new();
963        raw.insert("InstallFlatpak".to_string(), "high".to_string());
964        let table = PolicyTable::from_overrides(&raw).expect("lowercase accepted");
965        assert_eq!(
966            table.min_role_for_action("InstallFlatpak"),
967            Some(CallerRole::Admin)
968        );
969
970        let mut raw2 = HashMap::new();
971        raw2.insert("InstallFlatpak".to_string(), "  HIGH  ".to_string());
972        let table2 = PolicyTable::from_overrides(&raw2).expect("trim + uppercase");
973        assert_eq!(
974            table2.min_role_for_action("InstallFlatpak"),
975            Some(CallerRole::Admin)
976        );
977    }
978
979    #[test]
980    fn active_overrides_returns_sorted_entries() {
981        let mut raw = HashMap::new();
982        raw.insert("InstallFlatpak".to_string(), "High".to_string());
983        raw.insert("CreateContainer".to_string(), "High".to_string());
984        let table = PolicyTable::from_overrides(&raw).unwrap();
985
986        let active = table.active_overrides();
987        assert_eq!(active.len(), 2);
988        assert_eq!(active[0].0, "CreateContainer");
989        assert_eq!(active[0].1, CallerRole::Admin);
990        assert_eq!(active[1].0, "InstallFlatpak");
991        assert_eq!(active[1].1, CallerRole::Admin);
992    }
993
994    #[test]
995    fn unknown_action_denied_under_override_table_too() {
996        let table = PolicyTable::empty();
997        assert!(!table.action_allowed(&CallerRole::Admin, "NonExistent"));
998        assert_eq!(table.min_role_for_action("NonExistent"), None);
999    }
1000
1001    #[test]
1002    fn role_for_risk_level_mapping() {
1003        assert_eq!(role_for_risk_level(RiskLevel::Low), CallerRole::Observer);
1004        assert_eq!(role_for_risk_level(RiskLevel::Medium), CallerRole::Dev);
1005        assert_eq!(role_for_risk_level(RiskLevel::High), CallerRole::Admin);
1006    }
1007
1008    #[test]
1009    fn dns_and_apparmor_mutations_require_admin() {
1010        // Regression: ResolvectlSetDns (DNS-hijack primitive, parity with
1011        // SetDnsServers) and AppArmorComplain (disables MAC enforcement, inverse
1012        // of AppArmorEnforce) were previously Dev-gated. Both are High-risk and
1013        // must require Admin so they align with role_for_risk_level(High).
1014        assert_eq!(
1015            min_role_for_action("ResolvectlSetDns"),
1016            Some(CallerRole::Admin)
1017        );
1018        assert_eq!(
1019            min_role_for_action("SetDnsServers"),
1020            min_role_for_action("ResolvectlSetDns"),
1021            "ResolvectlSetDns must match SetDnsServers"
1022        );
1023        assert_eq!(
1024            min_role_for_action("AppArmorComplain"),
1025            Some(CallerRole::Admin)
1026        );
1027        assert_eq!(
1028            min_role_for_action("AppArmorEnforce"),
1029            min_role_for_action("AppArmorComplain"),
1030            "AppArmorComplain must match AppArmorEnforce"
1031        );
1032    }
1033
1034    #[test]
1035    fn group_lifecycle_is_admin_and_listening_ports_is_observer() {
1036        // CreateGroup/DeleteGroup are privilege-relevant → Admin, matching the
1037        // rest of the user/group family.
1038        assert_eq!(min_role_for_action("CreateGroup"), Some(CallerRole::Admin));
1039        assert_eq!(min_role_for_action("DeleteGroup"), Some(CallerRole::Admin));
1040        assert!(!action_allowed(&CallerRole::Dev, "CreateGroup"));
1041        assert!(action_allowed(&CallerRole::Admin, "DeleteGroup"));
1042        // GetListeningPorts is a read-only diagnostic → Observer.
1043        assert_eq!(
1044            min_role_for_action("GetListeningPorts"),
1045            Some(CallerRole::Observer)
1046        );
1047        assert!(action_allowed(&CallerRole::Observer, "GetListeningPorts"));
1048    }
1049
1050    #[test]
1051    fn process_and_account_control_require_admin() {
1052        for action in [
1053            "SignalProcess",
1054            "LockUserAccount",
1055            "UnlockUserAccount",
1056            "SetSshdOption",
1057            "ConfigureUnattendedUpgrades",
1058            "CreateScheduledJob",
1059        ] {
1060            assert_eq!(
1061                min_role_for_action(action),
1062                Some(CallerRole::Admin),
1063                "{action} must require Admin"
1064            );
1065            assert!(
1066                !action_allowed(&CallerRole::Dev, action),
1067                "{action} must reject Dev"
1068            );
1069        }
1070    }
1071}