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