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/// Minimum [`CallerRole`] required to call `action_name`, or `None` if the
25/// action is unknown (no `ActionSpec`).
26///
27/// **The role is derived from the action's risk tier.** Risk lives once on the
28/// action's `ActionSpec` (single source of truth, `crate::actions`) and maps to
29/// a role via [`role_for_risk_level`] (`Low`→Observer, `Medium`→Dev,
30/// `High`→Admin), so RBAC can never drift from the documented risk. A short,
31/// *monotonic* exception list (`role_exception`) may raise an action's role
32/// above its risk floor when it is more sensitive than its risk implies; an
33/// exception may never lower it. Pinned by
34/// `role_mirrors_risk_except_documented_monotonic_exceptions` in
35/// `tests/action_consistency.rs`.
36pub fn min_role_for_action(action_name: &str) -> Option<CallerRole> {
37    if let Some(role) = role_exception(action_name) {
38        return Some(role);
39    }
40    crate::actions::spec_meta(action_name).map(|meta| role_for_risk_level(meta.risk_level))
41}
42
43/// Explicit role for actions that cannot derive one from a risk tier: actions
44/// with no `ActionSpec`, and any intentional *monotonic* raise (a role strictly
45/// higher than `role_for_risk_level(risk)` — never lower). Pinned by tests.
46fn role_exception(action_name: &str) -> Option<CallerRole> {
47    match action_name {
48        // Dispatcher-internal history query: no ActionSpec; read-only → Observer.
49        "ListJobHistory" => Some(CallerRole::Observer),
50        _ => None,
51    }
52}
53
54/// Check whether `caller` is authorized to invoke `action_name` against the
55/// **compile-time baseline** (no operator overrides).
56///
57/// Returns `true` if the action is known and the caller's role meets or
58/// exceeds the minimum role required by the per-action allowlist. Returns
59/// `false` for unknown actions (the caller should surface a validation error
60/// separately).
61///
62/// Production code paths should go through [`PolicyTable::action_allowed`]
63/// to honour `[policy.risk_overrides]`. This free function is kept for tests
64/// and call sites that genuinely need the baseline (e.g. validating that an
65/// override is a *raise*).
66pub fn action_allowed(caller: &CallerRole, action_name: &str) -> bool {
67    match min_role_for_action(action_name) {
68        Some(required) => role_rank(caller) >= role_rank(&required),
69        None => false,
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Operator overrides — `[policy.risk_overrides]`
75// ---------------------------------------------------------------------------
76
77/// Per-action authorization policy with optional operator overrides.
78///
79/// Wraps the compile-time baseline ([`min_role_for_action`]) with a map of
80/// per-action overrides loaded from `[policy.risk_overrides]` in
81/// `~/.config/sysknife/config.toml`.
82///
83/// **Security invariant: overrides may only raise the minimum role.** A
84/// downgrade attempt is rejected at construction time so a misconfigured —
85/// or maliciously modified — config can never silently grant escalated access.
86#[derive(Clone, Debug, Default)]
87pub struct PolicyTable {
88    /// Action name → effective minimum `CallerRole`. Always >= the baseline.
89    overrides: HashMap<String, CallerRole>,
90}
91
92/// Errors that can occur while validating `[policy.risk_overrides]` at startup.
93#[derive(Debug, thiserror::Error, PartialEq, Eq)]
94pub enum PolicyValidationError {
95    #[error("unknown action in [policy.risk_overrides]: {action}")]
96    UnknownAction { action: String },
97
98    #[error(
99        "invalid risk level {value:?} for action {action}: \
100         must be \"Low\", \"Medium\", or \"High\""
101    )]
102    InvalidRiskLevel { action: String, value: String },
103
104    #[error(
105        "policy override for {action} would lower the minimum role from \
106         {baseline:?} to {attempted:?}; overrides may only raise"
107    )]
108    CannotLower {
109        action: String,
110        baseline: CallerRole,
111        attempted: CallerRole,
112    },
113}
114
115impl PolicyTable {
116    /// Construct an empty policy table — no overrides, identical behaviour
117    /// to the compile-time baseline.
118    pub fn empty() -> Self {
119        Self {
120            overrides: HashMap::new(),
121        }
122    }
123
124    /// Construct a policy table from a `risk_overrides` map.
125    ///
126    /// Validates each entry:
127    /// - the action name must be known to [`min_role_for_action`];
128    /// - the risk level must parse to `Low`/`Medium`/`High`;
129    /// - the resulting role must be **at or above** the compile-time baseline.
130    ///
131    /// On any violation, returns the first error encountered (callers should
132    /// surface it as a fatal startup error so operator typos are loud).
133    pub fn from_overrides(raw: &HashMap<String, String>) -> Result<Self, PolicyValidationError> {
134        let mut overrides = HashMap::with_capacity(raw.len());
135        for (action, level_str) in raw {
136            let baseline = min_role_for_action(action).ok_or_else(|| {
137                PolicyValidationError::UnknownAction {
138                    action: action.clone(),
139                }
140            })?;
141
142            let level = parse_risk_level(level_str).ok_or_else(|| {
143                PolicyValidationError::InvalidRiskLevel {
144                    action: action.clone(),
145                    value: level_str.clone(),
146                }
147            })?;
148
149            let attempted = role_for_risk_level(level);
150
151            if role_rank(&attempted) < role_rank(&baseline) {
152                return Err(PolicyValidationError::CannotLower {
153                    action: action.clone(),
154                    baseline,
155                    attempted,
156                });
157            }
158
159            overrides.insert(action.clone(), attempted);
160        }
161        Ok(Self { overrides })
162    }
163
164    /// Effective minimum `CallerRole` for `action_name`, accounting for
165    /// overrides. Returns `None` for unknown actions.
166    pub fn min_role_for_action(&self, action_name: &str) -> Option<CallerRole> {
167        let baseline = min_role_for_action(action_name)?;
168        // Override-or-baseline; the constructor guarantees overrides never lower.
169        Some(self.overrides.get(action_name).copied().unwrap_or(baseline))
170    }
171
172    /// Whether `caller` is authorized to invoke `action_name` under this
173    /// table. Returns `false` for unknown actions.
174    pub fn action_allowed(&self, caller: &CallerRole, action_name: &str) -> bool {
175        match self.min_role_for_action(action_name) {
176            Some(required) => role_rank(caller) >= role_rank(&required),
177            None => false,
178        }
179    }
180
181    /// Active overrides, sorted by action name. Suitable for an INFO-level
182    /// startup log.
183    pub fn active_overrides(&self) -> Vec<(&str, CallerRole)> {
184        let mut entries: Vec<(&str, CallerRole)> = self
185            .overrides
186            .iter()
187            .map(|(k, v)| (k.as_str(), *v))
188            .collect();
189        entries.sort_by_key(|(k, _)| *k);
190        entries
191    }
192
193    /// Number of active overrides.
194    pub fn override_count(&self) -> usize {
195        self.overrides.len()
196    }
197}
198
199/// Map a [`RiskLevel`] to the [`CallerRole`] it requires under the baseline.
200///
201/// `Low` → `Observer`, `Medium` → `Dev`, `High` → `Admin`. This is the baseline
202/// [`min_role_for_action`] derives from; `tests/action_consistency.rs` uses it to
203/// pin the role↔risk invariant.
204pub fn role_for_risk_level(level: RiskLevel) -> CallerRole {
205    match level {
206        RiskLevel::Low => CallerRole::Observer,
207        RiskLevel::Medium => CallerRole::Dev,
208        RiskLevel::High => CallerRole::Admin,
209    }
210}
211
212/// Case-insensitive parse of `"Low"` / `"Medium"` / `"High"` into [`RiskLevel`].
213fn parse_risk_level(s: &str) -> Option<RiskLevel> {
214    match s.trim().to_ascii_lowercase().as_str() {
215        "low" => Some(RiskLevel::Low),
216        "medium" => Some(RiskLevel::Medium),
217        "high" => Some(RiskLevel::High),
218        _ => None,
219    }
220}
221
222// ---------------------------------------------------------------------------
223// Tests
224// ---------------------------------------------------------------------------
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    // ------------------------------------------------------------------
231    // Observer — can call read-only actions
232    // ------------------------------------------------------------------
233
234    #[test]
235    fn observer_can_call_read_only_actions() {
236        let role = CallerRole::Observer;
237        assert!(action_allowed(&role, "GetSystemState"));
238        assert!(action_allowed(&role, "CollectDiagnostics"));
239        assert!(action_allowed(&role, "GetDeploymentHistory"));
240        assert!(action_allowed(&role, "ListDeployments"));
241        assert!(action_allowed(&role, "GetKernelArguments"));
242        assert!(action_allowed(&role, "ListFlatpakRemotes"));
243        assert!(action_allowed(&role, "SearchFlatpakApps"));
244        assert!(action_allowed(&role, "GetFlatpakAppInfo"));
245        assert!(action_allowed(&role, "ListContainers"));
246        assert!(action_allowed(&role, "GetContainerInfo"));
247        assert!(action_allowed(&role, "GetLayeredPackages"));
248        assert!(action_allowed(&role, "ListPackageRepositories"));
249        assert!(action_allowed(&role, "ListServices"));
250        assert!(action_allowed(&role, "GetServiceLogs"));
251        assert!(action_allowed(&role, "ListToolboxes"));
252        assert!(action_allowed(&role, "GetFirewallState"));
253        assert!(action_allowed(&role, "ListUsers"));
254        assert!(action_allowed(&role, "ListGroups"));
255        assert!(action_allowed(&role, "GetDiskUsage"));
256        assert!(action_allowed(&role, "ListProcesses"));
257        assert!(action_allowed(&role, "GetMemoryInfo"));
258        assert!(action_allowed(&role, "GetNetworkStatus"));
259        assert!(action_allowed(&role, "GetAuthorizedKeys"));
260        assert!(action_allowed(&role, "ListJobHistory"));
261    }
262
263    // ------------------------------------------------------------------
264    // Observer — cannot call medium or high risk actions
265    // ------------------------------------------------------------------
266
267    #[test]
268    fn observer_cannot_call_medium_or_high_risk_actions() {
269        let role = CallerRole::Observer;
270        // Medium-risk
271        assert!(!action_allowed(&role, "InstallFlatpak"));
272        assert!(!action_allowed(&role, "RemoveFlatpak"));
273        assert!(!action_allowed(&role, "CreateContainer"));
274        assert!(!action_allowed(&role, "StartService"));
275        assert!(!action_allowed(&role, "CreateToolbox"));
276        assert!(!action_allowed(&role, "SetHostname"));
277        assert!(!action_allowed(&role, "ConfigureWifi"));
278        assert!(!action_allowed(&role, "AddPackageRepository"));
279        // High-risk
280        assert!(!action_allowed(&role, "UpdateSystem"));
281        assert!(!action_allowed(&role, "RebaseSystem"));
282        assert!(!action_allowed(&role, "InstallPackages"));
283        assert!(!action_allowed(&role, "AddUserToGroup"));
284        assert!(!action_allowed(&role, "RebootSystem"));
285        assert!(!action_allowed(&role, "SetKernelArguments"));
286    }
287
288    // ------------------------------------------------------------------
289    // Dev — can call medium risk actions (and all observer actions)
290    // ------------------------------------------------------------------
291
292    #[test]
293    fn dev_can_call_medium_risk_actions() {
294        let role = CallerRole::Dev;
295        // Medium-risk
296        assert!(action_allowed(&role, "InstallFlatpak"));
297        assert!(action_allowed(&role, "RemoveFlatpak"));
298        assert!(action_allowed(&role, "UpdateFlatpak"));
299        assert!(action_allowed(&role, "AddFlatpakRemote"));
300        assert!(action_allowed(&role, "RemoveFlatpakRemote"));
301        assert!(action_allowed(&role, "CreateContainer"));
302        assert!(action_allowed(&role, "StartContainer"));
303        assert!(action_allowed(&role, "StopContainer"));
304        assert!(action_allowed(&role, "RemoveContainer"));
305        assert!(action_allowed(&role, "StartService"));
306        assert!(action_allowed(&role, "StopService"));
307        assert!(action_allowed(&role, "RestartService"));
308        assert!(action_allowed(&role, "ReloadService"));
309        assert!(action_allowed(&role, "ReloadDaemon"));
310        assert!(action_allowed(&role, "SetServiceEnabled"));
311        assert!(action_allowed(&role, "UnmaskService"));
312        assert!(action_allowed(&role, "CreateToolbox"));
313        assert!(action_allowed(&role, "RemoveToolbox"));
314        assert!(action_allowed(&role, "SetHostname"));
315        assert!(action_allowed(&role, "SetTimezone"));
316        assert!(action_allowed(&role, "SetLocale"));
317        assert!(action_allowed(&role, "SetNtp"));
318        assert!(action_allowed(&role, "RemovePackageRepository"));
319        assert!(action_allowed(&role, "EnablePackageRepository"));
320        assert!(action_allowed(&role, "DisablePackageRepository"));
321        // Observer-level actions still allowed
322        assert!(action_allowed(&role, "GetSystemState"));
323        assert!(action_allowed(&role, "ListServices"));
324        assert!(action_allowed(&role, "ListContainers"));
325        assert!(action_allowed(&role, "ListInstalledFlatpaks"));
326        assert!(action_allowed(&role, "GetPendingUpdates"));
327        assert!(action_allowed(&role, "GetServiceStatus"));
328        assert!(action_allowed(&role, "ListTimers"));
329    }
330
331    // ------------------------------------------------------------------
332    // Dev — cannot call high risk actions
333    // ------------------------------------------------------------------
334
335    #[test]
336    fn dev_cannot_call_high_risk_actions() {
337        let role = CallerRole::Dev;
338        assert!(!action_allowed(&role, "UpdateSystem"));
339        assert!(!action_allowed(&role, "PinDeployment"));
340        assert!(!action_allowed(&role, "UnpinDeployment"));
341        assert!(!action_allowed(&role, "RebaseSystem"));
342        assert!(!action_allowed(&role, "CleanupDeployments"));
343        assert!(!action_allowed(&role, "RebootSystem"));
344        assert!(!action_allowed(&role, "RollbackDeployment"));
345        assert!(!action_allowed(&role, "SetKernelArguments"));
346        assert!(!action_allowed(&role, "InstallPackages"));
347        assert!(!action_allowed(&role, "RemovePackages"));
348        assert!(!action_allowed(&role, "AddLayeredPackage"));
349        assert!(!action_allowed(&role, "RemoveLayeredPackage"));
350        assert!(!action_allowed(&role, "ReplaceLayeredPackage"));
351        assert!(!action_allowed(&role, "ResetLayeredPackageOverride"));
352        assert!(!action_allowed(&role, "RemoveBasePackage"));
353        assert!(!action_allowed(&role, "AddUserToGroup"));
354        assert!(!action_allowed(&role, "RemoveUserFromGroup"));
355        // Access-control operations require Admin
356        assert!(!action_allowed(&role, "DeleteUser"));
357        assert!(!action_allowed(&role, "AddAuthorizedKey"));
358        assert!(!action_allowed(&role, "RemoveAuthorizedKey"));
359        // Security-critical ops reclassified to Admin (NIST 800-53 / CIS v8.1)
360        assert!(!action_allowed(&role, "CreateUser"));
361        assert!(!action_allowed(&role, "ConfigureFirewall"));
362        assert!(!action_allowed(&role, "MaskService"));
363        assert!(!action_allowed(&role, "AddPackageRepository"));
364        assert!(!action_allowed(&role, "SetDnsServers"));
365    }
366
367    // ------------------------------------------------------------------
368    // Admin — can call high risk actions (and all lower)
369    // ------------------------------------------------------------------
370
371    #[test]
372    fn admin_can_call_high_risk_actions() {
373        let role = CallerRole::Admin;
374        // High-risk
375        assert!(action_allowed(&role, "UpdateSystem"));
376        assert!(action_allowed(&role, "PinDeployment"));
377        assert!(action_allowed(&role, "UnpinDeployment"));
378        assert!(action_allowed(&role, "RebaseSystem"));
379        assert!(action_allowed(&role, "CleanupDeployments"));
380        assert!(action_allowed(&role, "RebootSystem"));
381        assert!(action_allowed(&role, "RollbackDeployment"));
382        assert!(action_allowed(&role, "SetKernelArguments"));
383        assert!(action_allowed(&role, "InstallPackages"));
384        assert!(action_allowed(&role, "RemovePackages"));
385        assert!(action_allowed(&role, "AddLayeredPackage"));
386        assert!(action_allowed(&role, "RemoveLayeredPackage"));
387        assert!(action_allowed(&role, "ReplaceLayeredPackage"));
388        assert!(action_allowed(&role, "ResetLayeredPackageOverride"));
389        assert!(action_allowed(&role, "RemoveBasePackage"));
390        assert!(action_allowed(&role, "AddUserToGroup"));
391        assert!(action_allowed(&role, "RemoveUserFromGroup"));
392        assert!(action_allowed(&role, "DeleteUser"));
393        assert!(action_allowed(&role, "AddAuthorizedKey"));
394        assert!(action_allowed(&role, "RemoveAuthorizedKey"));
395        // Security-critical ops reclassified to Admin
396        assert!(action_allowed(&role, "CreateUser"));
397        assert!(action_allowed(&role, "ConfigureFirewall"));
398        assert!(action_allowed(&role, "MaskService"));
399        assert!(action_allowed(&role, "AddPackageRepository"));
400        assert!(action_allowed(&role, "SetDnsServers"));
401        // Medium-risk still allowed
402        assert!(action_allowed(&role, "InstallFlatpak"));
403        assert!(action_allowed(&role, "UpdateFlatpak"));
404        assert!(action_allowed(&role, "CreateToolbox"));
405        assert!(action_allowed(&role, "StartService"));
406        assert!(action_allowed(&role, "ReloadService"));
407        assert!(action_allowed(&role, "ReloadDaemon"));
408        // Observer-level still allowed
409        assert!(action_allowed(&role, "GetSystemState"));
410        assert!(action_allowed(&role, "ListUsers"));
411        assert!(action_allowed(&role, "ListInstalledFlatpaks"));
412        assert!(action_allowed(&role, "GetPendingUpdates"));
413        assert!(action_allowed(&role, "GetServiceStatus"));
414        assert!(action_allowed(&role, "ListTimers"));
415    }
416
417    // ------------------------------------------------------------------
418    // Boot — can call everything
419    // ------------------------------------------------------------------
420
421    #[test]
422    fn boot_can_call_everything() {
423        let role = CallerRole::Boot;
424        // Sample from each tier
425        assert!(action_allowed(&role, "GetSystemState"));
426        assert!(action_allowed(&role, "ListDeployments"));
427        assert!(action_allowed(&role, "ListContainers"));
428        assert!(action_allowed(&role, "GetFirewallState"));
429        assert!(action_allowed(&role, "ListInstalledFlatpaks"));
430        assert!(action_allowed(&role, "GetPendingUpdates"));
431        assert!(action_allowed(&role, "GetServiceStatus"));
432        assert!(action_allowed(&role, "ListTimers"));
433        assert!(action_allowed(&role, "InstallFlatpak"));
434        assert!(action_allowed(&role, "UpdateFlatpak"));
435        assert!(action_allowed(&role, "CreateToolbox"));
436        assert!(action_allowed(&role, "StartService"));
437        assert!(action_allowed(&role, "ReloadService"));
438        assert!(action_allowed(&role, "ReloadDaemon"));
439        assert!(action_allowed(&role, "SetHostname"));
440        assert!(action_allowed(&role, "ConfigureWifi"));
441        assert!(action_allowed(&role, "CreateUser"));
442        assert!(action_allowed(&role, "UpdateSystem"));
443        assert!(action_allowed(&role, "RebaseSystem"));
444        assert!(action_allowed(&role, "RebootSystem"));
445        assert!(action_allowed(&role, "InstallPackages"));
446        assert!(action_allowed(&role, "RemoveBasePackage"));
447        assert!(action_allowed(&role, "AddUserToGroup"));
448        assert!(action_allowed(&role, "RemoveUserFromGroup"));
449        assert!(action_allowed(&role, "DeleteUser"));
450        assert!(action_allowed(&role, "AddAuthorizedKey"));
451        assert!(action_allowed(&role, "RemoveAuthorizedKey"));
452    }
453
454    // ------------------------------------------------------------------
455    // Security reclassification: five actions require Admin, not Dev
456    //
457    // Rationale (NIST 800-53 / CIS Controls v8.1 / MITRE ATT&CK):
458    //   CreateUser       — T1136.001 Persistence; NIST AC-2 High-baseline
459    //   ConfigureFirewall — T1562.004 Defense Evasion; NIST SC-7 Moderate+
460    //   MaskService      — T1562.001 Impair Defenses; permanent/irreversible
461    //   AddPackageRepository — supply chain vector; NIST SI-7/CM-7 Moderate+
462    //   SetDnsServers    — DNS hijacking / MitM (T1557 path); NIST SC-7
463    // ------------------------------------------------------------------
464
465    #[test]
466    fn dev_cannot_create_user() {
467        assert!(!action_allowed(&CallerRole::Dev, "CreateUser"));
468    }
469
470    #[test]
471    fn dev_cannot_configure_firewall() {
472        assert!(!action_allowed(&CallerRole::Dev, "ConfigureFirewall"));
473    }
474
475    #[test]
476    fn dev_cannot_mask_service() {
477        assert!(!action_allowed(&CallerRole::Dev, "MaskService"));
478    }
479
480    #[test]
481    fn dev_cannot_add_package_repository() {
482        assert!(!action_allowed(&CallerRole::Dev, "AddPackageRepository"));
483    }
484
485    #[test]
486    fn dev_cannot_set_dns_servers() {
487        assert!(!action_allowed(&CallerRole::Dev, "SetDnsServers"));
488    }
489
490    #[test]
491    fn admin_can_create_user() {
492        assert!(action_allowed(&CallerRole::Admin, "CreateUser"));
493    }
494
495    #[test]
496    fn admin_can_configure_firewall() {
497        assert!(action_allowed(&CallerRole::Admin, "ConfigureFirewall"));
498    }
499
500    #[test]
501    fn admin_can_mask_service() {
502        assert!(action_allowed(&CallerRole::Admin, "MaskService"));
503    }
504
505    #[test]
506    fn admin_can_add_package_repository() {
507        assert!(action_allowed(&CallerRole::Admin, "AddPackageRepository"));
508    }
509
510    #[test]
511    fn admin_can_set_dns_servers() {
512        assert!(action_allowed(&CallerRole::Admin, "SetDnsServers"));
513    }
514
515    // ------------------------------------------------------------------
516    // Unknown actions are denied
517    // ------------------------------------------------------------------
518
519    #[test]
520    fn unknown_action_denied_for_all_roles() {
521        assert!(!action_allowed(&CallerRole::Observer, "NonExistent"));
522        assert!(!action_allowed(&CallerRole::Dev, "NonExistent"));
523        assert!(!action_allowed(&CallerRole::Admin, "NonExistent"));
524        assert!(!action_allowed(&CallerRole::Boot, "NonExistent"));
525    }
526
527    // ------------------------------------------------------------------
528    // PolicyTable — operator overrides
529    // ------------------------------------------------------------------
530
531    #[test]
532    fn empty_policy_table_matches_baseline() {
533        let table = PolicyTable::empty();
534        assert_eq!(
535            table.min_role_for_action("InstallFlatpak"),
536            Some(CallerRole::Dev)
537        );
538        assert_eq!(
539            table.min_role_for_action("UpdateSystem"),
540            Some(CallerRole::Admin)
541        );
542        assert!(table.action_allowed(&CallerRole::Dev, "InstallFlatpak"));
543        assert!(!table.action_allowed(&CallerRole::Dev, "UpdateSystem"));
544        assert_eq!(table.override_count(), 0);
545    }
546
547    #[test]
548    fn override_raises_minimum_role() {
549        let mut raw = HashMap::new();
550        raw.insert("InstallFlatpak".to_string(), "High".to_string());
551        let table = PolicyTable::from_overrides(&raw).expect("valid raise");
552
553        assert_eq!(
554            table.min_role_for_action("InstallFlatpak"),
555            Some(CallerRole::Admin)
556        );
557        // Dev was previously allowed; now needs Admin.
558        assert!(!table.action_allowed(&CallerRole::Dev, "InstallFlatpak"));
559        assert!(table.action_allowed(&CallerRole::Admin, "InstallFlatpak"));
560        // Other actions unchanged.
561        assert_eq!(
562            table.min_role_for_action("GetSystemState"),
563            Some(CallerRole::Observer)
564        );
565    }
566
567    #[test]
568    fn override_at_baseline_is_noop() {
569        // GetSystemState baseline = Low (Observer). Setting "Low" is a no-op raise.
570        let mut raw = HashMap::new();
571        raw.insert("GetSystemState".to_string(), "Low".to_string());
572        let table = PolicyTable::from_overrides(&raw).expect("same-level override");
573        assert_eq!(
574            table.min_role_for_action("GetSystemState"),
575            Some(CallerRole::Observer)
576        );
577    }
578
579    #[test]
580    fn override_below_baseline_is_rejected() {
581        // UpdateSystem baseline = High (Admin). Trying to lower to Medium (Dev) must fail.
582        let mut raw = HashMap::new();
583        raw.insert("UpdateSystem".to_string(), "Medium".to_string());
584        let err = PolicyTable::from_overrides(&raw).expect_err("downgrade rejected");
585        match err {
586            PolicyValidationError::CannotLower {
587                action,
588                baseline,
589                attempted,
590            } => {
591                assert_eq!(action, "UpdateSystem");
592                assert_eq!(baseline, CallerRole::Admin);
593                assert_eq!(attempted, CallerRole::Dev);
594            }
595            other => panic!("wrong error variant: {other:?}"),
596        }
597    }
598
599    #[test]
600    fn override_unknown_action_is_rejected() {
601        let mut raw = HashMap::new();
602        raw.insert("DefinitelyNotAnAction".to_string(), "High".to_string());
603        let err = PolicyTable::from_overrides(&raw).expect_err("unknown action rejected");
604        match err {
605            PolicyValidationError::UnknownAction { action } => {
606                assert_eq!(action, "DefinitelyNotAnAction");
607            }
608            other => panic!("wrong error variant: {other:?}"),
609        }
610    }
611
612    #[test]
613    fn invalid_risk_level_is_rejected() {
614        let mut raw = HashMap::new();
615        raw.insert("InstallFlatpak".to_string(), "Critical".to_string());
616        let err = PolicyTable::from_overrides(&raw).expect_err("invalid level rejected");
617        match err {
618            PolicyValidationError::InvalidRiskLevel { action, value } => {
619                assert_eq!(action, "InstallFlatpak");
620                assert_eq!(value, "Critical");
621            }
622            other => panic!("wrong error variant: {other:?}"),
623        }
624    }
625
626    #[test]
627    fn risk_level_parsing_is_case_insensitive() {
628        let mut raw = HashMap::new();
629        raw.insert("InstallFlatpak".to_string(), "high".to_string());
630        let table = PolicyTable::from_overrides(&raw).expect("lowercase accepted");
631        assert_eq!(
632            table.min_role_for_action("InstallFlatpak"),
633            Some(CallerRole::Admin)
634        );
635
636        let mut raw2 = HashMap::new();
637        raw2.insert("InstallFlatpak".to_string(), "  HIGH  ".to_string());
638        let table2 = PolicyTable::from_overrides(&raw2).expect("trim + uppercase");
639        assert_eq!(
640            table2.min_role_for_action("InstallFlatpak"),
641            Some(CallerRole::Admin)
642        );
643    }
644
645    #[test]
646    fn active_overrides_returns_sorted_entries() {
647        let mut raw = HashMap::new();
648        raw.insert("InstallFlatpak".to_string(), "High".to_string());
649        raw.insert("CreateContainer".to_string(), "High".to_string());
650        let table = PolicyTable::from_overrides(&raw).unwrap();
651
652        let active = table.active_overrides();
653        assert_eq!(active.len(), 2);
654        assert_eq!(active[0].0, "CreateContainer");
655        assert_eq!(active[0].1, CallerRole::Admin);
656        assert_eq!(active[1].0, "InstallFlatpak");
657        assert_eq!(active[1].1, CallerRole::Admin);
658    }
659
660    #[test]
661    fn unknown_action_denied_under_override_table_too() {
662        let table = PolicyTable::empty();
663        assert!(!table.action_allowed(&CallerRole::Admin, "NonExistent"));
664        assert_eq!(table.min_role_for_action("NonExistent"), None);
665    }
666
667    #[test]
668    fn role_for_risk_level_mapping() {
669        assert_eq!(role_for_risk_level(RiskLevel::Low), CallerRole::Observer);
670        assert_eq!(role_for_risk_level(RiskLevel::Medium), CallerRole::Dev);
671        assert_eq!(role_for_risk_level(RiskLevel::High), CallerRole::Admin);
672    }
673
674    #[test]
675    fn dns_and_apparmor_mutations_require_admin() {
676        // Regression: ResolvectlSetDns (DNS-hijack primitive, parity with
677        // SetDnsServers) and AppArmorComplain (disables MAC enforcement, inverse
678        // of AppArmorEnforce) were previously Dev-gated. Both are High-risk and
679        // must require Admin so they align with role_for_risk_level(High).
680        assert_eq!(
681            min_role_for_action("ResolvectlSetDns"),
682            Some(CallerRole::Admin)
683        );
684        assert_eq!(
685            min_role_for_action("SetDnsServers"),
686            min_role_for_action("ResolvectlSetDns"),
687            "ResolvectlSetDns must match SetDnsServers"
688        );
689        assert_eq!(
690            min_role_for_action("AppArmorComplain"),
691            Some(CallerRole::Admin)
692        );
693        assert_eq!(
694            min_role_for_action("AppArmorEnforce"),
695            min_role_for_action("AppArmorComplain"),
696            "AppArmorComplain must match AppArmorEnforce"
697        );
698    }
699
700    #[test]
701    fn group_create_is_dev_delete_is_admin_and_listening_ports_is_observer() {
702        // CreateGroup (Medium risk — an empty group grants nothing) → Dev;
703        // DeleteGroup (High risk — can break references / lock out) → Admin.
704        assert_eq!(min_role_for_action("CreateGroup"), Some(CallerRole::Dev));
705        assert_eq!(min_role_for_action("DeleteGroup"), Some(CallerRole::Admin));
706        assert!(action_allowed(&CallerRole::Dev, "CreateGroup"));
707        assert!(!action_allowed(&CallerRole::Dev, "DeleteGroup"));
708        assert!(action_allowed(&CallerRole::Admin, "DeleteGroup"));
709        // GetListeningPorts is a read-only diagnostic → Observer.
710        assert_eq!(
711            min_role_for_action("GetListeningPorts"),
712            Some(CallerRole::Observer)
713        );
714        assert!(action_allowed(&CallerRole::Observer, "GetListeningPorts"));
715    }
716
717    #[test]
718    fn process_and_account_control_require_admin() {
719        for action in [
720            "SignalProcess",
721            "LockUserAccount",
722            "UnlockUserAccount",
723            "SetSshdOption",
724            "ConfigureUnattendedUpgrades",
725            "CreateScheduledJob",
726        ] {
727            assert_eq!(
728                min_role_for_action(action),
729                Some(CallerRole::Admin),
730                "{action} must require Admin"
731            );
732            assert!(
733                !action_allowed(&CallerRole::Dev, action),
734                "{action} must reject Dev"
735            );
736        }
737    }
738}