1use std::collections::{BTreeMap, BTreeSet};
36
37use serde::{Deserialize, Serialize};
38
39#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
41pub struct CommandPath(Vec<String>);
42
43impl CommandPath {
44 pub fn new<I, S>(segments: I) -> Self
57 where
58 I: IntoIterator<Item = S>,
59 S: Into<String>,
60 {
61 Self(
62 segments
63 .into_iter()
64 .map(Into::into)
65 .map(|segment| segment.trim().to_ascii_lowercase())
66 .filter(|segment| !segment.is_empty())
67 .collect(),
68 )
69 }
70
71 pub fn as_slice(&self) -> &[String] {
73 self.0.as_slice()
74 }
75
76 pub fn is_empty(&self) -> bool {
78 self.0.is_empty()
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum VisibilityMode {
86 Public,
88 Authenticated,
90 CapabilityGated,
92 Hidden,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum CommandAvailability {
99 Available,
101 Disabled,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107#[must_use]
108pub struct CommandPolicy {
109 pub path: CommandPath,
111 pub visibility: VisibilityMode,
113 pub availability: CommandAvailability,
115 pub required_capabilities: BTreeSet<String>,
117 pub feature_flags: BTreeSet<String>,
119 pub allowed_profiles: Option<BTreeSet<String>>,
121 pub denied_message: Option<String>,
123 pub hidden_reason: Option<String>,
125}
126
127impl CommandPolicy {
128 pub fn new(path: CommandPath) -> Self {
147 Self {
148 path,
149 visibility: VisibilityMode::Public,
150 availability: CommandAvailability::Available,
151 required_capabilities: BTreeSet::new(),
152 feature_flags: BTreeSet::new(),
153 allowed_profiles: None,
154 denied_message: None,
155 hidden_reason: None,
156 }
157 }
158
159 pub fn visibility(mut self, visibility: VisibilityMode) -> Self {
161 self.visibility = visibility;
162 self
163 }
164
165 pub fn require_capability(mut self, capability: impl Into<String>) -> Self {
167 let normalized = capability.into().trim().to_ascii_lowercase();
168 if !normalized.is_empty() {
169 self.required_capabilities.insert(normalized);
170 }
171 self
172 }
173
174 pub fn feature_flag(mut self, flag: impl Into<String>) -> Self {
176 let normalized = flag.into().trim().to_ascii_lowercase();
177 if !normalized.is_empty() {
178 self.feature_flags.insert(normalized);
179 }
180 self
181 }
182
183 pub fn allow_profiles<I, S>(mut self, profiles: I) -> Self
185 where
186 I: IntoIterator<Item = S>,
187 S: Into<String>,
188 {
189 let values = profiles
190 .into_iter()
191 .map(Into::into)
192 .map(|profile| profile.trim().to_ascii_lowercase())
193 .filter(|profile| !profile.is_empty())
194 .collect::<BTreeSet<_>>();
195 self.allowed_profiles = (!values.is_empty()).then_some(values);
196 self
197 }
198
199 pub fn denied_message(mut self, message: impl Into<String>) -> Self {
201 let normalized = message.into().trim().to_string();
202 self.denied_message = (!normalized.is_empty()).then_some(normalized);
203 self
204 }
205
206 pub fn hidden_reason(mut self, reason: impl Into<String>) -> Self {
208 let normalized = reason.into().trim().to_string();
209 self.hidden_reason = (!normalized.is_empty()).then_some(normalized);
210 self
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Default)]
216#[non_exhaustive]
217#[must_use]
218pub struct CommandPolicyOverride {
219 pub visibility: Option<VisibilityMode>,
221 pub availability: Option<CommandAvailability>,
223 pub required_capabilities: BTreeSet<String>,
225 pub hidden_reason: Option<String>,
227 pub denied_message: Option<String>,
229}
230
231impl CommandPolicyOverride {
232 pub fn new() -> Self {
257 Self::default()
258 }
259
260 pub fn with_visibility(mut self, visibility: Option<VisibilityMode>) -> Self {
262 self.visibility = visibility;
263 self
264 }
265
266 pub fn with_availability(mut self, availability: Option<CommandAvailability>) -> Self {
268 self.availability = availability;
269 self
270 }
271
272 pub fn with_required_capabilities<I, S>(mut self, required_capabilities: I) -> Self
274 where
275 I: IntoIterator<Item = S>,
276 S: Into<String>,
277 {
278 self.required_capabilities = required_capabilities
279 .into_iter()
280 .map(Into::into)
281 .map(|capability| capability.trim().to_ascii_lowercase())
282 .filter(|capability| !capability.is_empty())
283 .collect();
284 self
285 }
286
287 pub fn with_hidden_reason(mut self, hidden_reason: Option<String>) -> Self {
289 self.hidden_reason = hidden_reason
290 .map(|reason| reason.trim().to_string())
291 .filter(|reason| !reason.is_empty());
292 self
293 }
294
295 pub fn with_denied_message(mut self, denied_message: Option<String>) -> Self {
297 self.denied_message = denied_message
298 .map(|message| message.trim().to_string())
299 .filter(|message| !message.is_empty());
300 self
301 }
302}
303
304#[derive(Debug, Clone, Default, PartialEq, Eq)]
306#[must_use]
307pub struct CommandPolicyContext {
308 pub authenticated: bool,
310 pub capabilities: BTreeSet<String>,
312 pub enabled_features: BTreeSet<String>,
314 pub active_profile: Option<String>,
316}
317
318impl CommandPolicyContext {
319 pub fn authenticated(mut self, value: bool) -> Self {
321 self.authenticated = value;
322 self
323 }
324
325 pub fn with_capabilities<I, S>(mut self, capabilities: I) -> Self
327 where
328 I: IntoIterator<Item = S>,
329 S: Into<String>,
330 {
331 self.capabilities = capabilities
332 .into_iter()
333 .map(Into::into)
334 .map(|capability| capability.trim().to_ascii_lowercase())
335 .filter(|capability| !capability.is_empty())
336 .collect();
337 self
338 }
339
340 pub fn with_features<I, S>(mut self, features: I) -> Self
342 where
343 I: IntoIterator<Item = S>,
344 S: Into<String>,
345 {
346 self.enabled_features = features
347 .into_iter()
348 .map(Into::into)
349 .map(|feature| feature.trim().to_ascii_lowercase())
350 .filter(|feature| !feature.is_empty())
351 .collect();
352 self
353 }
354
355 pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
357 let normalized = profile.into().trim().to_ascii_lowercase();
358 self.active_profile = (!normalized.is_empty()).then_some(normalized);
359 self
360 }
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum CommandVisibility {
366 Hidden,
368 Visible,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum CommandRunnable {
375 Runnable,
377 Denied,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq)]
383pub enum AccessReason {
384 HiddenByPolicy,
386 DisabledByProduct,
388 Unauthenticated,
390 MissingCapabilities,
392 FeatureDisabled(String),
394 ProfileUnavailable(String),
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct CommandAccess {
401 pub visibility: CommandVisibility,
403 pub runnable: CommandRunnable,
405 pub reasons: Vec<AccessReason>,
407 pub missing_capabilities: BTreeSet<String>,
409}
410
411impl CommandAccess {
412 pub fn visible_runnable() -> Self {
414 Self {
415 visibility: CommandVisibility::Visible,
416 runnable: CommandRunnable::Runnable,
417 reasons: Vec::new(),
418 missing_capabilities: BTreeSet::new(),
419 }
420 }
421
422 pub fn hidden(reason: AccessReason) -> Self {
424 Self {
425 visibility: CommandVisibility::Hidden,
426 runnable: CommandRunnable::Denied,
427 reasons: vec![reason],
428 missing_capabilities: BTreeSet::new(),
429 }
430 }
431
432 pub fn visible_denied(reason: AccessReason) -> Self {
434 Self {
435 visibility: CommandVisibility::Visible,
436 runnable: CommandRunnable::Denied,
437 reasons: vec![reason],
438 missing_capabilities: BTreeSet::new(),
439 }
440 }
441
442 pub fn is_visible(&self) -> bool {
444 matches!(self.visibility, CommandVisibility::Visible)
445 }
446
447 pub fn is_runnable(&self) -> bool {
449 matches!(self.runnable, CommandRunnable::Runnable)
450 }
451}
452
453#[derive(Debug, Clone, Default)]
455pub struct CommandPolicyRegistry {
456 entries: BTreeMap<CommandPath, CommandPolicy>,
457 overrides: BTreeMap<CommandPath, CommandPolicyOverride>,
458}
459
460impl CommandPolicyRegistry {
461 pub fn new() -> Self {
463 Self::default()
464 }
465
466 pub fn is_empty(&self) -> bool {
468 self.entries.is_empty()
469 }
470
471 pub fn register(&mut self, policy: CommandPolicy) -> Option<CommandPolicy> {
473 self.entries.insert(policy.path.clone(), policy)
474 }
475
476 pub fn override_policy(
478 &mut self,
479 path: CommandPath,
480 value: CommandPolicyOverride,
481 ) -> Option<CommandPolicyOverride> {
482 self.overrides.insert(path, value)
483 }
484
485 pub fn resolved_policy(&self, path: &CommandPath) -> Option<CommandPolicy> {
513 let mut policy = self.entries.get(path)?.clone();
514 if let Some(override_policy) = self.overrides.get(path) {
515 if let Some(visibility) = override_policy.visibility {
516 policy.visibility = visibility;
517 }
518 if let Some(availability) = override_policy.availability {
519 policy.availability = availability;
520 }
521 policy
522 .required_capabilities
523 .extend(override_policy.required_capabilities.iter().cloned());
524 if let Some(hidden_reason) = &override_policy.hidden_reason {
525 policy.hidden_reason = Some(hidden_reason.clone());
526 }
527 if let Some(denied_message) = &override_policy.denied_message {
528 policy.denied_message = Some(denied_message.clone());
529 }
530 }
531 Some(policy)
532 }
533
534 pub fn evaluate(
536 &self,
537 path: &CommandPath,
538 context: &CommandPolicyContext,
539 ) -> Option<CommandAccess> {
540 self.resolved_policy(path)
541 .map(|policy| evaluate_policy(&policy, context))
542 }
543
544 pub fn contains(&self, path: &CommandPath) -> bool {
546 self.entries.contains_key(path)
547 }
548
549 pub fn entries(&self) -> impl Iterator<Item = &CommandPolicy> {
551 self.entries.values()
552 }
553}
554
555pub fn evaluate_policy(policy: &CommandPolicy, context: &CommandPolicyContext) -> CommandAccess {
590 if matches!(policy.availability, CommandAvailability::Disabled) {
591 return CommandAccess::hidden(AccessReason::DisabledByProduct);
592 }
593 if matches!(policy.visibility, VisibilityMode::Hidden) {
594 return CommandAccess::hidden(AccessReason::HiddenByPolicy);
595 }
596 if let Some(allowed_profiles) = &policy.allowed_profiles {
597 match context.active_profile.as_ref() {
598 Some(profile) if allowed_profiles.contains(profile) => {}
599 Some(profile) => {
600 return CommandAccess::hidden(AccessReason::ProfileUnavailable(profile.clone()));
601 }
602 None => return CommandAccess::hidden(AccessReason::ProfileUnavailable(String::new())),
603 }
604 }
605 if let Some(feature) = policy
606 .feature_flags
607 .iter()
608 .find(|feature| !context.enabled_features.contains(*feature))
609 {
610 return CommandAccess::hidden(AccessReason::FeatureDisabled(feature.clone()));
611 }
612
613 match policy.visibility {
614 VisibilityMode::Public => CommandAccess::visible_runnable(),
615 VisibilityMode::Authenticated => {
616 if context.authenticated {
617 CommandAccess::visible_runnable()
618 } else {
619 CommandAccess::visible_denied(AccessReason::Unauthenticated)
620 }
621 }
622 VisibilityMode::CapabilityGated => {
623 if !context.authenticated {
624 return CommandAccess::visible_denied(AccessReason::Unauthenticated);
625 }
626 let missing = policy
627 .required_capabilities
628 .iter()
629 .filter(|capability| !context.capabilities.contains(*capability))
630 .cloned()
631 .collect::<BTreeSet<_>>();
632 if missing.is_empty() {
633 CommandAccess::visible_runnable()
634 } else {
635 CommandAccess {
636 visibility: CommandVisibility::Visible,
637 runnable: CommandRunnable::Denied,
638 reasons: vec![AccessReason::MissingCapabilities],
639 missing_capabilities: missing,
640 }
641 }
642 }
643 VisibilityMode::Hidden => CommandAccess::hidden(AccessReason::HiddenByPolicy),
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use std::collections::BTreeSet;
650
651 use super::{
652 AccessReason, CommandAccess, CommandAvailability, CommandPath, CommandPolicy,
653 CommandPolicyContext, CommandPolicyOverride, CommandPolicyRegistry, CommandRunnable,
654 CommandVisibility, VisibilityMode, evaluate_policy,
655 };
656
657 #[test]
658 fn command_path_and_policy_builders_normalize_inputs() {
659 let path = CommandPath::new([" Orch ", "", "Approval", " Decide "]);
660 assert_eq!(
661 path.as_slice(),
662 &[
663 "orch".to_string(),
664 "approval".to_string(),
665 "decide".to_string()
666 ]
667 );
668 assert!(!path.is_empty());
669 assert!(CommandPath::new(["", " "]).is_empty());
670
671 let policy = CommandPolicy::new(path.clone())
672 .visibility(VisibilityMode::CapabilityGated)
673 .require_capability(" Orch.Approval.Decide ")
674 .require_capability(" ")
675 .feature_flag(" Orch ")
676 .feature_flag("")
677 .allow_profiles([" Dev ", " ", "Prod"])
678 .denied_message(" Sign in first ")
679 .hidden_reason(" hidden upstream ");
680
681 assert_eq!(policy.path, path);
682 assert_eq!(policy.visibility, VisibilityMode::CapabilityGated);
683 assert_eq!(
684 policy.required_capabilities,
685 BTreeSet::from(["orch.approval.decide".to_string()])
686 );
687 assert_eq!(policy.feature_flags, BTreeSet::from(["orch".to_string()]));
688 assert_eq!(
689 policy.allowed_profiles,
690 Some(BTreeSet::from(["dev".to_string(), "prod".to_string()]))
691 );
692 assert_eq!(policy.denied_message.as_deref(), Some("Sign in first"));
693 assert_eq!(policy.hidden_reason.as_deref(), Some("hidden upstream"));
694 }
695
696 #[test]
697 fn policy_context_builders_normalize_inputs() {
698 let context = CommandPolicyContext::default()
699 .authenticated(true)
700 .with_capabilities([" Orch.Read ", "", "orch.write"])
701 .with_features([" Orch ", " "])
702 .with_profile(" Dev ");
703
704 assert!(context.authenticated);
705 assert_eq!(
706 context.capabilities,
707 BTreeSet::from(["orch.read".to_string(), "orch.write".to_string()])
708 );
709 assert_eq!(
710 context.enabled_features,
711 BTreeSet::from(["orch".to_string()])
712 );
713 assert_eq!(context.active_profile.as_deref(), Some("dev"));
714 assert_eq!(
715 CommandPolicyContext::default()
716 .with_profile(" ")
717 .active_profile,
718 None
719 );
720 }
721
722 #[test]
723 fn capability_gated_command_is_visible_but_denied_when_capability_missing() {
724 let mut registry = CommandPolicyRegistry::new();
725 let path = CommandPath::new(["orch", "approval", "decide"]);
726 registry.register(
727 CommandPolicy::new(path.clone())
728 .visibility(VisibilityMode::CapabilityGated)
729 .require_capability("orch.approval.decide"),
730 );
731
732 let access = registry
733 .evaluate(&path, &CommandPolicyContext::default().authenticated(true))
734 .expect("policy should exist");
735
736 assert_eq!(access.visibility, CommandVisibility::Visible);
737 assert_eq!(access.runnable, CommandRunnable::Denied);
738 assert_eq!(access.reasons, vec![AccessReason::MissingCapabilities]);
739 }
740
741 #[test]
742 fn required_capabilities_are_simple_conjunction() {
743 let mut registry = CommandPolicyRegistry::new();
744 let path = CommandPath::new(["orch", "policy", "add"]);
745 registry.register(
746 CommandPolicy::new(path.clone())
747 .visibility(VisibilityMode::CapabilityGated)
748 .require_capability("orch.policy.read")
749 .require_capability("orch.policy.write"),
750 );
751
752 let access = registry
753 .evaluate(
754 &path,
755 &CommandPolicyContext::default()
756 .authenticated(true)
757 .with_capabilities(["orch.policy.read"]),
758 )
759 .expect("policy should exist");
760
761 assert!(access.missing_capabilities.contains("orch.policy.write"));
762 }
763
764 #[test]
765 fn public_commands_can_remain_unauthenticated() {
766 let policy = CommandPolicy::new(CommandPath::new(["help"]));
767 let access = evaluate_policy(&policy, &CommandPolicyContext::default());
768 assert_eq!(access, CommandAccess::visible_runnable());
769 }
770
771 #[test]
772 fn overrides_can_hide_commands() {
773 let mut registry = CommandPolicyRegistry::new();
774 let path = CommandPath::new(["nh", "audit"]);
775 registry.register(CommandPolicy::new(path.clone()));
776 registry.override_policy(
777 path.clone(),
778 CommandPolicyOverride::new().with_visibility(Some(VisibilityMode::Hidden)),
779 );
780
781 let access = registry
782 .evaluate(&path, &CommandPolicyContext::default())
783 .expect("policy should exist");
784 assert_eq!(access.visibility, CommandVisibility::Hidden);
785 }
786
787 #[test]
788 fn access_helpers_reflect_visibility_and_runnability() {
789 let access = CommandAccess::visible_denied(AccessReason::Unauthenticated);
790 assert!(access.is_visible());
791 assert!(!access.is_runnable());
792 }
793
794 #[test]
795 fn evaluate_policy_covers_disabled_hidden_feature_profile_and_auth_variants() {
796 let disabled = CommandPolicy::new(CommandPath::new(["orch"]))
797 .visibility(VisibilityMode::Authenticated);
798 let mut disabled = disabled;
799 disabled.availability = CommandAvailability::Disabled;
800 assert_eq!(
801 evaluate_policy(&disabled, &CommandPolicyContext::default()),
802 CommandAccess::hidden(AccessReason::DisabledByProduct)
803 );
804
805 let hidden =
806 CommandPolicy::new(CommandPath::new(["orch"])).visibility(VisibilityMode::Hidden);
807 assert_eq!(
808 evaluate_policy(&hidden, &CommandPolicyContext::default()),
809 CommandAccess::hidden(AccessReason::HiddenByPolicy)
810 );
811
812 let profiled = CommandPolicy::new(CommandPath::new(["orch"]))
813 .allow_profiles(["dev"])
814 .feature_flag("orch");
815 assert_eq!(
816 evaluate_policy(&profiled, &CommandPolicyContext::default()),
817 CommandAccess::hidden(AccessReason::ProfileUnavailable(String::new()))
818 );
819 assert_eq!(
820 evaluate_policy(
821 &profiled,
822 &CommandPolicyContext::default().with_profile("prod")
823 ),
824 CommandAccess::hidden(AccessReason::ProfileUnavailable("prod".to_string()))
825 );
826 assert_eq!(
827 evaluate_policy(
828 &profiled,
829 &CommandPolicyContext::default().with_profile("dev")
830 ),
831 CommandAccess::hidden(AccessReason::FeatureDisabled("orch".to_string()))
832 );
833
834 let auth_only = CommandPolicy::new(CommandPath::new(["auth", "status"]))
835 .visibility(VisibilityMode::Authenticated);
836 assert_eq!(
837 evaluate_policy(&auth_only, &CommandPolicyContext::default()),
838 CommandAccess::visible_denied(AccessReason::Unauthenticated)
839 );
840 assert_eq!(
841 evaluate_policy(
842 &auth_only,
843 &CommandPolicyContext::default().authenticated(true)
844 ),
845 CommandAccess::visible_runnable()
846 );
847
848 let capability = CommandPolicy::new(CommandPath::new(["orch", "approval"]))
849 .visibility(VisibilityMode::CapabilityGated)
850 .require_capability("orch.approval.decide");
851 assert_eq!(
852 evaluate_policy(
853 &capability,
854 &CommandPolicyContext::default()
855 .authenticated(true)
856 .with_capabilities(["orch.approval.decide"])
857 ),
858 CommandAccess::visible_runnable()
859 );
860 }
861
862 #[test]
863 fn registry_resolution_applies_overrides_and_contains_lookup() {
864 let path = CommandPath::new(["orch", "policy"]);
865 let mut registry = CommandPolicyRegistry::new();
866 assert!(!registry.contains(&path));
867 assert!(registry.resolved_policy(&path).is_none());
868
869 registry.register(
870 CommandPolicy::new(path.clone())
871 .visibility(VisibilityMode::Authenticated)
872 .allow_profiles(["dev"])
873 .denied_message("sign in")
874 .hidden_reason("base hidden"),
875 );
876 assert!(registry.contains(&path));
877
878 registry.override_policy(
879 path.clone(),
880 CommandPolicyOverride::new()
881 .with_visibility(Some(VisibilityMode::CapabilityGated))
882 .with_availability(Some(CommandAvailability::Disabled))
883 .with_required_capabilities(["orch.policy.write"])
884 .with_hidden_reason(Some("override hidden".to_string()))
885 .with_denied_message(Some("override denied".to_string())),
886 );
887
888 let resolved = registry
889 .resolved_policy(&path)
890 .expect("policy should resolve");
891 assert_eq!(resolved.visibility, VisibilityMode::CapabilityGated);
892 assert_eq!(resolved.availability, CommandAvailability::Disabled);
893 assert_eq!(
894 resolved.required_capabilities,
895 BTreeSet::from(["orch.policy.write".to_string()])
896 );
897 assert_eq!(resolved.hidden_reason.as_deref(), Some("override hidden"));
898 assert_eq!(resolved.denied_message.as_deref(), Some("override denied"));
899 assert_eq!(
900 registry.evaluate(&path, &CommandPolicyContext::default()),
901 Some(CommandAccess::hidden(AccessReason::DisabledByProduct))
902 );
903 }
904}