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)]
107pub struct CommandPolicy {
108 pub path: CommandPath,
110 pub visibility: VisibilityMode,
112 pub availability: CommandAvailability,
114 pub required_capabilities: BTreeSet<String>,
116 pub feature_flags: BTreeSet<String>,
118 pub allowed_profiles: Option<BTreeSet<String>>,
120 pub denied_message: Option<String>,
122 pub hidden_reason: Option<String>,
124}
125
126impl CommandPolicy {
127 pub fn new(path: CommandPath) -> Self {
145 Self {
146 path,
147 visibility: VisibilityMode::Public,
148 availability: CommandAvailability::Available,
149 required_capabilities: BTreeSet::new(),
150 feature_flags: BTreeSet::new(),
151 allowed_profiles: None,
152 denied_message: None,
153 hidden_reason: None,
154 }
155 }
156
157 pub fn visibility(mut self, visibility: VisibilityMode) -> Self {
159 self.visibility = visibility;
160 self
161 }
162
163 pub fn require_capability(mut self, capability: impl Into<String>) -> Self {
165 let normalized = capability.into().trim().to_ascii_lowercase();
166 if !normalized.is_empty() {
167 self.required_capabilities.insert(normalized);
168 }
169 self
170 }
171
172 pub fn feature_flag(mut self, flag: impl Into<String>) -> Self {
174 let normalized = flag.into().trim().to_ascii_lowercase();
175 if !normalized.is_empty() {
176 self.feature_flags.insert(normalized);
177 }
178 self
179 }
180
181 pub fn allow_profiles<I, S>(mut self, profiles: I) -> Self
183 where
184 I: IntoIterator<Item = S>,
185 S: Into<String>,
186 {
187 let values = profiles
188 .into_iter()
189 .map(Into::into)
190 .map(|profile| profile.trim().to_ascii_lowercase())
191 .filter(|profile| !profile.is_empty())
192 .collect::<BTreeSet<_>>();
193 self.allowed_profiles = (!values.is_empty()).then_some(values);
194 self
195 }
196
197 pub fn denied_message(mut self, message: impl Into<String>) -> Self {
199 let normalized = message.into().trim().to_string();
200 self.denied_message = (!normalized.is_empty()).then_some(normalized);
201 self
202 }
203
204 pub fn hidden_reason(mut self, reason: impl Into<String>) -> Self {
206 let normalized = reason.into().trim().to_string();
207 self.hidden_reason = (!normalized.is_empty()).then_some(normalized);
208 self
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Default)]
214#[non_exhaustive]
215pub struct CommandPolicyOverride {
216 pub visibility: Option<VisibilityMode>,
218 pub availability: Option<CommandAvailability>,
220 pub required_capabilities: BTreeSet<String>,
222 pub hidden_reason: Option<String>,
224 pub denied_message: Option<String>,
226}
227
228impl CommandPolicyOverride {
229 pub fn new() -> Self {
254 Self::default()
255 }
256
257 pub fn with_visibility(mut self, visibility: Option<VisibilityMode>) -> Self {
259 self.visibility = visibility;
260 self
261 }
262
263 pub fn with_availability(mut self, availability: Option<CommandAvailability>) -> Self {
265 self.availability = availability;
266 self
267 }
268
269 pub fn with_required_capabilities<I, S>(mut self, required_capabilities: I) -> Self
271 where
272 I: IntoIterator<Item = S>,
273 S: Into<String>,
274 {
275 self.required_capabilities = required_capabilities
276 .into_iter()
277 .map(Into::into)
278 .map(|capability| capability.trim().to_ascii_lowercase())
279 .filter(|capability| !capability.is_empty())
280 .collect();
281 self
282 }
283
284 pub fn with_hidden_reason(mut self, hidden_reason: Option<String>) -> Self {
286 self.hidden_reason = hidden_reason
287 .map(|reason| reason.trim().to_string())
288 .filter(|reason| !reason.is_empty());
289 self
290 }
291
292 pub fn with_denied_message(mut self, denied_message: Option<String>) -> Self {
294 self.denied_message = denied_message
295 .map(|message| message.trim().to_string())
296 .filter(|message| !message.is_empty());
297 self
298 }
299}
300
301#[derive(Debug, Clone, Default, PartialEq, Eq)]
303pub struct CommandPolicyContext {
304 pub authenticated: bool,
306 pub capabilities: BTreeSet<String>,
308 pub enabled_features: BTreeSet<String>,
310 pub active_profile: Option<String>,
312}
313
314impl CommandPolicyContext {
315 pub fn authenticated(mut self, value: bool) -> Self {
317 self.authenticated = value;
318 self
319 }
320
321 pub fn with_capabilities<I, S>(mut self, capabilities: I) -> Self
323 where
324 I: IntoIterator<Item = S>,
325 S: Into<String>,
326 {
327 self.capabilities = capabilities
328 .into_iter()
329 .map(Into::into)
330 .map(|capability| capability.trim().to_ascii_lowercase())
331 .filter(|capability| !capability.is_empty())
332 .collect();
333 self
334 }
335
336 pub fn with_features<I, S>(mut self, features: I) -> Self
338 where
339 I: IntoIterator<Item = S>,
340 S: Into<String>,
341 {
342 self.enabled_features = features
343 .into_iter()
344 .map(Into::into)
345 .map(|feature| feature.trim().to_ascii_lowercase())
346 .filter(|feature| !feature.is_empty())
347 .collect();
348 self
349 }
350
351 pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
353 let normalized = profile.into().trim().to_ascii_lowercase();
354 self.active_profile = (!normalized.is_empty()).then_some(normalized);
355 self
356 }
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum CommandVisibility {
362 Hidden,
364 Visible,
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370pub enum CommandRunnable {
371 Runnable,
373 Denied,
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
379pub enum AccessReason {
380 HiddenByPolicy,
382 DisabledByProduct,
384 Unauthenticated,
386 MissingCapabilities,
388 FeatureDisabled(String),
390 ProfileUnavailable(String),
392}
393
394#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct CommandAccess {
397 pub visibility: CommandVisibility,
399 pub runnable: CommandRunnable,
401 pub reasons: Vec<AccessReason>,
403 pub missing_capabilities: BTreeSet<String>,
405}
406
407impl CommandAccess {
408 pub fn visible_runnable() -> Self {
410 Self {
411 visibility: CommandVisibility::Visible,
412 runnable: CommandRunnable::Runnable,
413 reasons: Vec::new(),
414 missing_capabilities: BTreeSet::new(),
415 }
416 }
417
418 pub fn hidden(reason: AccessReason) -> Self {
420 Self {
421 visibility: CommandVisibility::Hidden,
422 runnable: CommandRunnable::Denied,
423 reasons: vec![reason],
424 missing_capabilities: BTreeSet::new(),
425 }
426 }
427
428 pub fn visible_denied(reason: AccessReason) -> Self {
430 Self {
431 visibility: CommandVisibility::Visible,
432 runnable: CommandRunnable::Denied,
433 reasons: vec![reason],
434 missing_capabilities: BTreeSet::new(),
435 }
436 }
437
438 pub fn is_visible(&self) -> bool {
440 matches!(self.visibility, CommandVisibility::Visible)
441 }
442
443 pub fn is_runnable(&self) -> bool {
445 matches!(self.runnable, CommandRunnable::Runnable)
446 }
447}
448
449#[derive(Debug, Clone, Default)]
451pub struct CommandPolicyRegistry {
452 entries: BTreeMap<CommandPath, CommandPolicy>,
453 overrides: BTreeMap<CommandPath, CommandPolicyOverride>,
454}
455
456impl CommandPolicyRegistry {
457 pub fn new() -> Self {
459 Self::default()
460 }
461
462 pub fn register(&mut self, policy: CommandPolicy) -> Option<CommandPolicy> {
464 self.entries.insert(policy.path.clone(), policy)
465 }
466
467 pub fn override_policy(
469 &mut self,
470 path: CommandPath,
471 value: CommandPolicyOverride,
472 ) -> Option<CommandPolicyOverride> {
473 self.overrides.insert(path, value)
474 }
475
476 pub fn resolved_policy(&self, path: &CommandPath) -> Option<CommandPolicy> {
504 let mut policy = self.entries.get(path)?.clone();
505 if let Some(override_policy) = self.overrides.get(path) {
506 if let Some(visibility) = override_policy.visibility {
507 policy.visibility = visibility;
508 }
509 if let Some(availability) = override_policy.availability {
510 policy.availability = availability;
511 }
512 policy
513 .required_capabilities
514 .extend(override_policy.required_capabilities.iter().cloned());
515 if let Some(hidden_reason) = &override_policy.hidden_reason {
516 policy.hidden_reason = Some(hidden_reason.clone());
517 }
518 if let Some(denied_message) = &override_policy.denied_message {
519 policy.denied_message = Some(denied_message.clone());
520 }
521 }
522 Some(policy)
523 }
524
525 pub fn evaluate(
527 &self,
528 path: &CommandPath,
529 context: &CommandPolicyContext,
530 ) -> Option<CommandAccess> {
531 self.resolved_policy(path)
532 .map(|policy| evaluate_policy(&policy, context))
533 }
534
535 pub fn contains(&self, path: &CommandPath) -> bool {
537 self.entries.contains_key(path)
538 }
539
540 pub fn entries(&self) -> impl Iterator<Item = &CommandPolicy> {
542 self.entries.values()
543 }
544}
545
546pub fn evaluate_policy(policy: &CommandPolicy, context: &CommandPolicyContext) -> CommandAccess {
581 if matches!(policy.availability, CommandAvailability::Disabled) {
582 return CommandAccess::hidden(AccessReason::DisabledByProduct);
583 }
584 if matches!(policy.visibility, VisibilityMode::Hidden) {
585 return CommandAccess::hidden(AccessReason::HiddenByPolicy);
586 }
587 if let Some(allowed_profiles) = &policy.allowed_profiles {
588 match context.active_profile.as_ref() {
589 Some(profile) if allowed_profiles.contains(profile) => {}
590 Some(profile) => {
591 return CommandAccess::hidden(AccessReason::ProfileUnavailable(profile.clone()));
592 }
593 None => return CommandAccess::hidden(AccessReason::ProfileUnavailable(String::new())),
594 }
595 }
596 if let Some(feature) = policy
597 .feature_flags
598 .iter()
599 .find(|feature| !context.enabled_features.contains(*feature))
600 {
601 return CommandAccess::hidden(AccessReason::FeatureDisabled(feature.clone()));
602 }
603
604 match policy.visibility {
605 VisibilityMode::Public => CommandAccess::visible_runnable(),
606 VisibilityMode::Authenticated => {
607 if context.authenticated {
608 CommandAccess::visible_runnable()
609 } else {
610 CommandAccess::visible_denied(AccessReason::Unauthenticated)
611 }
612 }
613 VisibilityMode::CapabilityGated => {
614 if !context.authenticated {
615 return CommandAccess::visible_denied(AccessReason::Unauthenticated);
616 }
617 let missing = policy
618 .required_capabilities
619 .iter()
620 .filter(|capability| !context.capabilities.contains(*capability))
621 .cloned()
622 .collect::<BTreeSet<_>>();
623 if missing.is_empty() {
624 CommandAccess::visible_runnable()
625 } else {
626 CommandAccess {
627 visibility: CommandVisibility::Visible,
628 runnable: CommandRunnable::Denied,
629 reasons: vec![AccessReason::MissingCapabilities],
630 missing_capabilities: missing,
631 }
632 }
633 }
634 VisibilityMode::Hidden => CommandAccess::hidden(AccessReason::HiddenByPolicy),
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use std::collections::BTreeSet;
641
642 use super::{
643 AccessReason, CommandAccess, CommandAvailability, CommandPath, CommandPolicy,
644 CommandPolicyContext, CommandPolicyOverride, CommandPolicyRegistry, CommandRunnable,
645 CommandVisibility, VisibilityMode, evaluate_policy,
646 };
647
648 #[test]
649 fn command_path_and_policy_builders_normalize_inputs() {
650 let path = CommandPath::new([" Orch ", "", "Approval", " Decide "]);
651 assert_eq!(
652 path.as_slice(),
653 &[
654 "orch".to_string(),
655 "approval".to_string(),
656 "decide".to_string()
657 ]
658 );
659 assert!(!path.is_empty());
660 assert!(CommandPath::new(["", " "]).is_empty());
661
662 let policy = CommandPolicy::new(path.clone())
663 .visibility(VisibilityMode::CapabilityGated)
664 .require_capability(" Orch.Approval.Decide ")
665 .require_capability(" ")
666 .feature_flag(" Orch ")
667 .feature_flag("")
668 .allow_profiles([" Dev ", " ", "Prod"])
669 .denied_message(" Sign in first ")
670 .hidden_reason(" hidden upstream ");
671
672 assert_eq!(policy.path, path);
673 assert_eq!(policy.visibility, VisibilityMode::CapabilityGated);
674 assert_eq!(
675 policy.required_capabilities,
676 BTreeSet::from(["orch.approval.decide".to_string()])
677 );
678 assert_eq!(policy.feature_flags, BTreeSet::from(["orch".to_string()]));
679 assert_eq!(
680 policy.allowed_profiles,
681 Some(BTreeSet::from(["dev".to_string(), "prod".to_string()]))
682 );
683 assert_eq!(policy.denied_message.as_deref(), Some("Sign in first"));
684 assert_eq!(policy.hidden_reason.as_deref(), Some("hidden upstream"));
685 }
686
687 #[test]
688 fn policy_context_builders_normalize_inputs() {
689 let context = CommandPolicyContext::default()
690 .authenticated(true)
691 .with_capabilities([" Orch.Read ", "", "orch.write"])
692 .with_features([" Orch ", " "])
693 .with_profile(" Dev ");
694
695 assert!(context.authenticated);
696 assert_eq!(
697 context.capabilities,
698 BTreeSet::from(["orch.read".to_string(), "orch.write".to_string()])
699 );
700 assert_eq!(
701 context.enabled_features,
702 BTreeSet::from(["orch".to_string()])
703 );
704 assert_eq!(context.active_profile.as_deref(), Some("dev"));
705 assert_eq!(
706 CommandPolicyContext::default()
707 .with_profile(" ")
708 .active_profile,
709 None
710 );
711 }
712
713 #[test]
714 fn capability_gated_command_is_visible_but_denied_when_capability_missing() {
715 let mut registry = CommandPolicyRegistry::new();
716 let path = CommandPath::new(["orch", "approval", "decide"]);
717 registry.register(
718 CommandPolicy::new(path.clone())
719 .visibility(VisibilityMode::CapabilityGated)
720 .require_capability("orch.approval.decide"),
721 );
722
723 let access = registry
724 .evaluate(&path, &CommandPolicyContext::default().authenticated(true))
725 .expect("policy should exist");
726
727 assert_eq!(access.visibility, CommandVisibility::Visible);
728 assert_eq!(access.runnable, CommandRunnable::Denied);
729 assert_eq!(access.reasons, vec![AccessReason::MissingCapabilities]);
730 }
731
732 #[test]
733 fn required_capabilities_are_simple_conjunction() {
734 let mut registry = CommandPolicyRegistry::new();
735 let path = CommandPath::new(["orch", "policy", "add"]);
736 registry.register(
737 CommandPolicy::new(path.clone())
738 .visibility(VisibilityMode::CapabilityGated)
739 .require_capability("orch.policy.read")
740 .require_capability("orch.policy.write"),
741 );
742
743 let access = registry
744 .evaluate(
745 &path,
746 &CommandPolicyContext::default()
747 .authenticated(true)
748 .with_capabilities(["orch.policy.read"]),
749 )
750 .expect("policy should exist");
751
752 assert!(access.missing_capabilities.contains("orch.policy.write"));
753 }
754
755 #[test]
756 fn public_commands_can_remain_unauthenticated() {
757 let policy = CommandPolicy::new(CommandPath::new(["help"]));
758 let access = evaluate_policy(&policy, &CommandPolicyContext::default());
759 assert_eq!(access, CommandAccess::visible_runnable());
760 }
761
762 #[test]
763 fn overrides_can_hide_commands() {
764 let mut registry = CommandPolicyRegistry::new();
765 let path = CommandPath::new(["nh", "audit"]);
766 registry.register(CommandPolicy::new(path.clone()));
767 registry.override_policy(
768 path.clone(),
769 CommandPolicyOverride::new().with_visibility(Some(VisibilityMode::Hidden)),
770 );
771
772 let access = registry
773 .evaluate(&path, &CommandPolicyContext::default())
774 .expect("policy should exist");
775 assert_eq!(access.visibility, CommandVisibility::Hidden);
776 }
777
778 #[test]
779 fn access_helpers_reflect_visibility_and_runnability() {
780 let access = CommandAccess::visible_denied(AccessReason::Unauthenticated);
781 assert!(access.is_visible());
782 assert!(!access.is_runnable());
783 }
784
785 #[test]
786 fn evaluate_policy_covers_disabled_hidden_feature_profile_and_auth_variants() {
787 let disabled = CommandPolicy::new(CommandPath::new(["orch"]))
788 .visibility(VisibilityMode::Authenticated);
789 let mut disabled = disabled;
790 disabled.availability = CommandAvailability::Disabled;
791 assert_eq!(
792 evaluate_policy(&disabled, &CommandPolicyContext::default()),
793 CommandAccess::hidden(AccessReason::DisabledByProduct)
794 );
795
796 let hidden =
797 CommandPolicy::new(CommandPath::new(["orch"])).visibility(VisibilityMode::Hidden);
798 assert_eq!(
799 evaluate_policy(&hidden, &CommandPolicyContext::default()),
800 CommandAccess::hidden(AccessReason::HiddenByPolicy)
801 );
802
803 let profiled = CommandPolicy::new(CommandPath::new(["orch"]))
804 .allow_profiles(["dev"])
805 .feature_flag("orch");
806 assert_eq!(
807 evaluate_policy(&profiled, &CommandPolicyContext::default()),
808 CommandAccess::hidden(AccessReason::ProfileUnavailable(String::new()))
809 );
810 assert_eq!(
811 evaluate_policy(
812 &profiled,
813 &CommandPolicyContext::default().with_profile("prod")
814 ),
815 CommandAccess::hidden(AccessReason::ProfileUnavailable("prod".to_string()))
816 );
817 assert_eq!(
818 evaluate_policy(
819 &profiled,
820 &CommandPolicyContext::default().with_profile("dev")
821 ),
822 CommandAccess::hidden(AccessReason::FeatureDisabled("orch".to_string()))
823 );
824
825 let auth_only = CommandPolicy::new(CommandPath::new(["auth", "status"]))
826 .visibility(VisibilityMode::Authenticated);
827 assert_eq!(
828 evaluate_policy(&auth_only, &CommandPolicyContext::default()),
829 CommandAccess::visible_denied(AccessReason::Unauthenticated)
830 );
831 assert_eq!(
832 evaluate_policy(
833 &auth_only,
834 &CommandPolicyContext::default().authenticated(true)
835 ),
836 CommandAccess::visible_runnable()
837 );
838
839 let capability = CommandPolicy::new(CommandPath::new(["orch", "approval"]))
840 .visibility(VisibilityMode::CapabilityGated)
841 .require_capability("orch.approval.decide");
842 assert_eq!(
843 evaluate_policy(
844 &capability,
845 &CommandPolicyContext::default()
846 .authenticated(true)
847 .with_capabilities(["orch.approval.decide"])
848 ),
849 CommandAccess::visible_runnable()
850 );
851 }
852
853 #[test]
854 fn registry_resolution_applies_overrides_and_contains_lookup() {
855 let path = CommandPath::new(["orch", "policy"]);
856 let mut registry = CommandPolicyRegistry::new();
857 assert!(!registry.contains(&path));
858 assert!(registry.resolved_policy(&path).is_none());
859
860 registry.register(
861 CommandPolicy::new(path.clone())
862 .visibility(VisibilityMode::Authenticated)
863 .allow_profiles(["dev"])
864 .denied_message("sign in")
865 .hidden_reason("base hidden"),
866 );
867 assert!(registry.contains(&path));
868
869 registry.override_policy(
870 path.clone(),
871 CommandPolicyOverride::new()
872 .with_visibility(Some(VisibilityMode::CapabilityGated))
873 .with_availability(Some(CommandAvailability::Disabled))
874 .with_required_capabilities(["orch.policy.write"])
875 .with_hidden_reason(Some("override hidden".to_string()))
876 .with_denied_message(Some("override denied".to_string())),
877 );
878
879 let resolved = registry
880 .resolved_policy(&path)
881 .expect("policy should resolve");
882 assert_eq!(resolved.visibility, VisibilityMode::CapabilityGated);
883 assert_eq!(resolved.availability, CommandAvailability::Disabled);
884 assert_eq!(
885 resolved.required_capabilities,
886 BTreeSet::from(["orch.policy.write".to_string()])
887 );
888 assert_eq!(resolved.hidden_reason.as_deref(), Some("override hidden"));
889 assert_eq!(resolved.denied_message.as_deref(), Some("override denied"));
890 assert_eq!(
891 registry.evaluate(&path, &CommandPolicyContext::default()),
892 Some(CommandAccess::hidden(AccessReason::DisabledByProduct))
893 );
894 }
895}