1use std::collections::HashMap;
15
16use sysknife_types::{CallerRole, RiskLevel};
17
18pub 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
41fn role_exception(action_name: &str) -> Option<CallerRole> {
45 match action_name {
46 "ListJobHistory" => Some(CallerRole::Observer),
48 _ => None,
49 }
50}
51
52pub 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#[derive(Clone, Debug, Default)]
85pub struct PolicyTable {
86 overrides: HashMap<String, CallerRole>,
88}
89
90#[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 pub fn empty() -> Self {
117 Self {
118 overrides: HashMap::new(),
119 }
120 }
121
122 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 pub fn min_role_for_action(&self, action_name: &str) -> Option<CallerRole> {
165 let baseline = min_role_for_action(action_name)?;
166 Some(self.overrides.get(action_name).copied().unwrap_or(baseline))
168 }
169
170 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 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 pub fn override_count(&self) -> usize {
193 self.overrides.len()
194 }
195}
196
197pub 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
210fn 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#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[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 #[test]
266 fn observer_cannot_call_medium_or_high_risk_actions() {
267 let role = CallerRole::Observer;
268 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 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 #[test]
291 fn dev_can_call_medium_risk_actions() {
292 let role = CallerRole::Dev;
293 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 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 #[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 assert!(!action_allowed(&role, "DeleteUser"));
355 assert!(!action_allowed(&role, "AddAuthorizedKey"));
356 assert!(!action_allowed(&role, "RemoveAuthorizedKey"));
357 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 #[test]
370 fn admin_can_call_high_risk_actions() {
371 let role = CallerRole::Admin;
372 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 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 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 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 #[test]
420 fn boot_can_call_everything() {
421 let role = CallerRole::Boot;
422 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 #[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 #[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 #[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 assert!(!table.action_allowed(&CallerRole::Dev, "InstallFlatpak"));
557 assert!(table.action_allowed(&CallerRole::Admin, "InstallFlatpak"));
558 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 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 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 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 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 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}