Skip to main content

osp_cli/core/
command_policy.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
6pub struct CommandPath(Vec<String>);
7
8impl CommandPath {
9    pub fn new<I, S>(segments: I) -> Self
10    where
11        I: IntoIterator<Item = S>,
12        S: Into<String>,
13    {
14        Self(
15            segments
16                .into_iter()
17                .map(Into::into)
18                .map(|segment| segment.trim().to_ascii_lowercase())
19                .filter(|segment| !segment.is_empty())
20                .collect(),
21        )
22    }
23
24    pub fn as_slice(&self) -> &[String] {
25        self.0.as_slice()
26    }
27
28    pub fn is_empty(&self) -> bool {
29        self.0.is_empty()
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum VisibilityMode {
36    Public,
37    Authenticated,
38    CapabilityGated,
39    Hidden,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum CommandAvailability {
44    Available,
45    Disabled,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct CommandPolicy {
50    pub path: CommandPath,
51    pub visibility: VisibilityMode,
52    pub availability: CommandAvailability,
53    pub required_capabilities: BTreeSet<String>,
54    pub feature_flags: BTreeSet<String>,
55    pub allowed_profiles: Option<BTreeSet<String>>,
56    pub denied_message: Option<String>,
57    pub hidden_reason: Option<String>,
58}
59
60impl CommandPolicy {
61    pub fn new(path: CommandPath) -> Self {
62        Self {
63            path,
64            visibility: VisibilityMode::Public,
65            availability: CommandAvailability::Available,
66            required_capabilities: BTreeSet::new(),
67            feature_flags: BTreeSet::new(),
68            allowed_profiles: None,
69            denied_message: None,
70            hidden_reason: None,
71        }
72    }
73
74    pub fn visibility(mut self, visibility: VisibilityMode) -> Self {
75        self.visibility = visibility;
76        self
77    }
78
79    pub fn require_capability(mut self, capability: impl Into<String>) -> Self {
80        let normalized = capability.into().trim().to_ascii_lowercase();
81        if !normalized.is_empty() {
82            self.required_capabilities.insert(normalized);
83        }
84        self
85    }
86
87    pub fn feature_flag(mut self, flag: impl Into<String>) -> Self {
88        let normalized = flag.into().trim().to_ascii_lowercase();
89        if !normalized.is_empty() {
90            self.feature_flags.insert(normalized);
91        }
92        self
93    }
94
95    pub fn allow_profiles<I, S>(mut self, profiles: I) -> Self
96    where
97        I: IntoIterator<Item = S>,
98        S: Into<String>,
99    {
100        let values = profiles
101            .into_iter()
102            .map(Into::into)
103            .map(|profile| profile.trim().to_ascii_lowercase())
104            .filter(|profile| !profile.is_empty())
105            .collect::<BTreeSet<_>>();
106        self.allowed_profiles = (!values.is_empty()).then_some(values);
107        self
108    }
109
110    pub fn denied_message(mut self, message: impl Into<String>) -> Self {
111        let normalized = message.into().trim().to_string();
112        self.denied_message = (!normalized.is_empty()).then_some(normalized);
113        self
114    }
115
116    pub fn hidden_reason(mut self, reason: impl Into<String>) -> Self {
117        let normalized = reason.into().trim().to_string();
118        self.hidden_reason = (!normalized.is_empty()).then_some(normalized);
119        self
120    }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Default)]
124pub struct CommandPolicyOverride {
125    pub visibility: Option<VisibilityMode>,
126    pub availability: Option<CommandAvailability>,
127    pub required_capabilities: BTreeSet<String>,
128    pub hidden_reason: Option<String>,
129    pub denied_message: Option<String>,
130}
131
132#[derive(Debug, Clone, Default, PartialEq, Eq)]
133pub struct CommandPolicyContext {
134    pub authenticated: bool,
135    pub capabilities: BTreeSet<String>,
136    pub enabled_features: BTreeSet<String>,
137    pub active_profile: Option<String>,
138}
139
140impl CommandPolicyContext {
141    pub fn authenticated(mut self, value: bool) -> Self {
142        self.authenticated = value;
143        self
144    }
145
146    pub fn with_capabilities<I, S>(mut self, capabilities: I) -> Self
147    where
148        I: IntoIterator<Item = S>,
149        S: Into<String>,
150    {
151        self.capabilities = capabilities
152            .into_iter()
153            .map(Into::into)
154            .map(|capability| capability.trim().to_ascii_lowercase())
155            .filter(|capability| !capability.is_empty())
156            .collect();
157        self
158    }
159
160    pub fn with_features<I, S>(mut self, features: I) -> Self
161    where
162        I: IntoIterator<Item = S>,
163        S: Into<String>,
164    {
165        self.enabled_features = features
166            .into_iter()
167            .map(Into::into)
168            .map(|feature| feature.trim().to_ascii_lowercase())
169            .filter(|feature| !feature.is_empty())
170            .collect();
171        self
172    }
173
174    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
175        let normalized = profile.into().trim().to_ascii_lowercase();
176        self.active_profile = (!normalized.is_empty()).then_some(normalized);
177        self
178    }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum CommandVisibility {
183    Hidden,
184    Visible,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum CommandRunnable {
189    Runnable,
190    Denied,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum AccessReason {
195    HiddenByPolicy,
196    DisabledByProduct,
197    Unauthenticated,
198    MissingCapabilities,
199    FeatureDisabled(String),
200    ProfileUnavailable(String),
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct CommandAccess {
205    pub visibility: CommandVisibility,
206    pub runnable: CommandRunnable,
207    pub reasons: Vec<AccessReason>,
208    pub missing_capabilities: BTreeSet<String>,
209}
210
211impl CommandAccess {
212    pub fn visible_runnable() -> Self {
213        Self {
214            visibility: CommandVisibility::Visible,
215            runnable: CommandRunnable::Runnable,
216            reasons: Vec::new(),
217            missing_capabilities: BTreeSet::new(),
218        }
219    }
220
221    pub fn hidden(reason: AccessReason) -> Self {
222        Self {
223            visibility: CommandVisibility::Hidden,
224            runnable: CommandRunnable::Denied,
225            reasons: vec![reason],
226            missing_capabilities: BTreeSet::new(),
227        }
228    }
229
230    pub fn visible_denied(reason: AccessReason) -> Self {
231        Self {
232            visibility: CommandVisibility::Visible,
233            runnable: CommandRunnable::Denied,
234            reasons: vec![reason],
235            missing_capabilities: BTreeSet::new(),
236        }
237    }
238
239    pub fn is_visible(&self) -> bool {
240        matches!(self.visibility, CommandVisibility::Visible)
241    }
242
243    pub fn is_runnable(&self) -> bool {
244        matches!(self.runnable, CommandRunnable::Runnable)
245    }
246}
247
248#[derive(Debug, Clone, Default)]
249pub struct CommandPolicyRegistry {
250    entries: BTreeMap<CommandPath, CommandPolicy>,
251    overrides: BTreeMap<CommandPath, CommandPolicyOverride>,
252}
253
254impl CommandPolicyRegistry {
255    pub fn new() -> Self {
256        Self::default()
257    }
258
259    pub fn register(&mut self, policy: CommandPolicy) -> Option<CommandPolicy> {
260        self.entries.insert(policy.path.clone(), policy)
261    }
262
263    pub fn override_policy(
264        &mut self,
265        path: CommandPath,
266        value: CommandPolicyOverride,
267    ) -> Option<CommandPolicyOverride> {
268        self.overrides.insert(path, value)
269    }
270
271    pub fn resolved_policy(&self, path: &CommandPath) -> Option<CommandPolicy> {
272        let mut policy = self.entries.get(path)?.clone();
273        if let Some(override_policy) = self.overrides.get(path) {
274            if let Some(visibility) = override_policy.visibility {
275                policy.visibility = visibility;
276            }
277            if let Some(availability) = override_policy.availability {
278                policy.availability = availability;
279            }
280            policy
281                .required_capabilities
282                .extend(override_policy.required_capabilities.iter().cloned());
283            if let Some(hidden_reason) = &override_policy.hidden_reason {
284                policy.hidden_reason = Some(hidden_reason.clone());
285            }
286            if let Some(denied_message) = &override_policy.denied_message {
287                policy.denied_message = Some(denied_message.clone());
288            }
289        }
290        Some(policy)
291    }
292
293    pub fn evaluate(
294        &self,
295        path: &CommandPath,
296        context: &CommandPolicyContext,
297    ) -> Option<CommandAccess> {
298        self.resolved_policy(path)
299            .map(|policy| evaluate_policy(&policy, context))
300    }
301
302    pub fn contains(&self, path: &CommandPath) -> bool {
303        self.entries.contains_key(path)
304    }
305}
306
307pub fn evaluate_policy(policy: &CommandPolicy, context: &CommandPolicyContext) -> CommandAccess {
308    if matches!(policy.availability, CommandAvailability::Disabled) {
309        return CommandAccess::hidden(AccessReason::DisabledByProduct);
310    }
311    if matches!(policy.visibility, VisibilityMode::Hidden) {
312        return CommandAccess::hidden(AccessReason::HiddenByPolicy);
313    }
314    if let Some(allowed_profiles) = &policy.allowed_profiles {
315        match context.active_profile.as_ref() {
316            Some(profile) if allowed_profiles.contains(profile) => {}
317            Some(profile) => {
318                return CommandAccess::hidden(AccessReason::ProfileUnavailable(profile.clone()));
319            }
320            None => return CommandAccess::hidden(AccessReason::ProfileUnavailable(String::new())),
321        }
322    }
323    if let Some(feature) = policy
324        .feature_flags
325        .iter()
326        .find(|feature| !context.enabled_features.contains(*feature))
327    {
328        return CommandAccess::hidden(AccessReason::FeatureDisabled(feature.clone()));
329    }
330
331    match policy.visibility {
332        VisibilityMode::Public => CommandAccess::visible_runnable(),
333        VisibilityMode::Authenticated => {
334            if context.authenticated {
335                CommandAccess::visible_runnable()
336            } else {
337                CommandAccess::visible_denied(AccessReason::Unauthenticated)
338            }
339        }
340        VisibilityMode::CapabilityGated => {
341            if !context.authenticated {
342                return CommandAccess::visible_denied(AccessReason::Unauthenticated);
343            }
344            let missing = policy
345                .required_capabilities
346                .iter()
347                .filter(|capability| !context.capabilities.contains(*capability))
348                .cloned()
349                .collect::<BTreeSet<_>>();
350            if missing.is_empty() {
351                CommandAccess::visible_runnable()
352            } else {
353                CommandAccess {
354                    visibility: CommandVisibility::Visible,
355                    runnable: CommandRunnable::Denied,
356                    reasons: vec![AccessReason::MissingCapabilities],
357                    missing_capabilities: missing,
358                }
359            }
360        }
361        VisibilityMode::Hidden => CommandAccess::hidden(AccessReason::HiddenByPolicy),
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use std::collections::BTreeSet;
368
369    use super::{
370        AccessReason, CommandAccess, CommandAvailability, CommandPath, CommandPolicy,
371        CommandPolicyContext, CommandPolicyOverride, CommandPolicyRegistry, CommandRunnable,
372        CommandVisibility, VisibilityMode, evaluate_policy,
373    };
374
375    #[test]
376    fn command_path_and_policy_builders_normalize_inputs() {
377        let path = CommandPath::new([" Orch ", "", "Approval", "  Decide  "]);
378        assert_eq!(
379            path.as_slice(),
380            &[
381                "orch".to_string(),
382                "approval".to_string(),
383                "decide".to_string()
384            ]
385        );
386        assert!(!path.is_empty());
387        assert!(CommandPath::new(["", "   "]).is_empty());
388
389        let policy = CommandPolicy::new(path.clone())
390            .visibility(VisibilityMode::CapabilityGated)
391            .require_capability(" Orch.Approval.Decide ")
392            .require_capability("   ")
393            .feature_flag(" Orch ")
394            .feature_flag("")
395            .allow_profiles([" Dev ", " ", "Prod"])
396            .denied_message("  Sign in first  ")
397            .hidden_reason("  hidden upstream  ");
398
399        assert_eq!(policy.path, path);
400        assert_eq!(policy.visibility, VisibilityMode::CapabilityGated);
401        assert_eq!(
402            policy.required_capabilities,
403            BTreeSet::from(["orch.approval.decide".to_string()])
404        );
405        assert_eq!(policy.feature_flags, BTreeSet::from(["orch".to_string()]));
406        assert_eq!(
407            policy.allowed_profiles,
408            Some(BTreeSet::from(["dev".to_string(), "prod".to_string()]))
409        );
410        assert_eq!(policy.denied_message.as_deref(), Some("Sign in first"));
411        assert_eq!(policy.hidden_reason.as_deref(), Some("hidden upstream"));
412    }
413
414    #[test]
415    fn policy_context_builders_normalize_inputs() {
416        let context = CommandPolicyContext::default()
417            .authenticated(true)
418            .with_capabilities([" Orch.Read ", "", "orch.write"])
419            .with_features([" Orch ", " "])
420            .with_profile(" Dev ");
421
422        assert!(context.authenticated);
423        assert_eq!(
424            context.capabilities,
425            BTreeSet::from(["orch.read".to_string(), "orch.write".to_string()])
426        );
427        assert_eq!(
428            context.enabled_features,
429            BTreeSet::from(["orch".to_string()])
430        );
431        assert_eq!(context.active_profile.as_deref(), Some("dev"));
432        assert_eq!(
433            CommandPolicyContext::default()
434                .with_profile("   ")
435                .active_profile,
436            None
437        );
438    }
439
440    #[test]
441    fn capability_gated_command_is_visible_but_denied_when_capability_missing() {
442        let mut registry = CommandPolicyRegistry::new();
443        let path = CommandPath::new(["orch", "approval", "decide"]);
444        registry.register(
445            CommandPolicy::new(path.clone())
446                .visibility(VisibilityMode::CapabilityGated)
447                .require_capability("orch.approval.decide"),
448        );
449
450        let access = registry
451            .evaluate(&path, &CommandPolicyContext::default().authenticated(true))
452            .expect("policy should exist");
453
454        assert_eq!(access.visibility, CommandVisibility::Visible);
455        assert_eq!(access.runnable, CommandRunnable::Denied);
456        assert_eq!(access.reasons, vec![AccessReason::MissingCapabilities]);
457    }
458
459    #[test]
460    fn required_capabilities_are_simple_conjunction() {
461        let mut registry = CommandPolicyRegistry::new();
462        let path = CommandPath::new(["orch", "policy", "add"]);
463        registry.register(
464            CommandPolicy::new(path.clone())
465                .visibility(VisibilityMode::CapabilityGated)
466                .require_capability("orch.policy.read")
467                .require_capability("orch.policy.write"),
468        );
469
470        let access = registry
471            .evaluate(
472                &path,
473                &CommandPolicyContext::default()
474                    .authenticated(true)
475                    .with_capabilities(["orch.policy.read"]),
476            )
477            .expect("policy should exist");
478
479        assert!(access.missing_capabilities.contains("orch.policy.write"));
480    }
481
482    #[test]
483    fn public_commands_can_remain_unauthenticated() {
484        let policy = CommandPolicy::new(CommandPath::new(["help"]));
485        let access = evaluate_policy(&policy, &CommandPolicyContext::default());
486        assert_eq!(access, CommandAccess::visible_runnable());
487    }
488
489    #[test]
490    fn overrides_can_hide_commands() {
491        let mut registry = CommandPolicyRegistry::new();
492        let path = CommandPath::new(["nh", "audit"]);
493        registry.register(CommandPolicy::new(path.clone()));
494        registry.override_policy(
495            path.clone(),
496            CommandPolicyOverride {
497                visibility: Some(VisibilityMode::Hidden),
498                ..CommandPolicyOverride::default()
499            },
500        );
501
502        let access = registry
503            .evaluate(&path, &CommandPolicyContext::default())
504            .expect("policy should exist");
505        assert_eq!(access.visibility, CommandVisibility::Hidden);
506    }
507
508    #[test]
509    fn access_helpers_reflect_visibility_and_runnability() {
510        let access = CommandAccess::visible_denied(AccessReason::Unauthenticated);
511        assert!(access.is_visible());
512        assert!(!access.is_runnable());
513    }
514
515    #[test]
516    fn evaluate_policy_covers_disabled_hidden_feature_profile_and_auth_variants() {
517        let disabled = CommandPolicy::new(CommandPath::new(["orch"]))
518            .visibility(VisibilityMode::Authenticated);
519        let mut disabled = disabled;
520        disabled.availability = CommandAvailability::Disabled;
521        assert_eq!(
522            evaluate_policy(&disabled, &CommandPolicyContext::default()),
523            CommandAccess::hidden(AccessReason::DisabledByProduct)
524        );
525
526        let hidden =
527            CommandPolicy::new(CommandPath::new(["orch"])).visibility(VisibilityMode::Hidden);
528        assert_eq!(
529            evaluate_policy(&hidden, &CommandPolicyContext::default()),
530            CommandAccess::hidden(AccessReason::HiddenByPolicy)
531        );
532
533        let profiled = CommandPolicy::new(CommandPath::new(["orch"]))
534            .allow_profiles(["dev"])
535            .feature_flag("orch");
536        assert_eq!(
537            evaluate_policy(&profiled, &CommandPolicyContext::default()),
538            CommandAccess::hidden(AccessReason::ProfileUnavailable(String::new()))
539        );
540        assert_eq!(
541            evaluate_policy(
542                &profiled,
543                &CommandPolicyContext::default().with_profile("prod")
544            ),
545            CommandAccess::hidden(AccessReason::ProfileUnavailable("prod".to_string()))
546        );
547        assert_eq!(
548            evaluate_policy(
549                &profiled,
550                &CommandPolicyContext::default().with_profile("dev")
551            ),
552            CommandAccess::hidden(AccessReason::FeatureDisabled("orch".to_string()))
553        );
554
555        let auth_only = CommandPolicy::new(CommandPath::new(["auth", "status"]))
556            .visibility(VisibilityMode::Authenticated);
557        assert_eq!(
558            evaluate_policy(&auth_only, &CommandPolicyContext::default()),
559            CommandAccess::visible_denied(AccessReason::Unauthenticated)
560        );
561        assert_eq!(
562            evaluate_policy(
563                &auth_only,
564                &CommandPolicyContext::default().authenticated(true)
565            ),
566            CommandAccess::visible_runnable()
567        );
568
569        let capability = CommandPolicy::new(CommandPath::new(["orch", "approval"]))
570            .visibility(VisibilityMode::CapabilityGated)
571            .require_capability("orch.approval.decide");
572        assert_eq!(
573            evaluate_policy(
574                &capability,
575                &CommandPolicyContext::default()
576                    .authenticated(true)
577                    .with_capabilities(["orch.approval.decide"])
578            ),
579            CommandAccess::visible_runnable()
580        );
581    }
582
583    #[test]
584    fn registry_resolution_applies_overrides_and_contains_lookup() {
585        let path = CommandPath::new(["orch", "policy"]);
586        let mut registry = CommandPolicyRegistry::new();
587        assert!(!registry.contains(&path));
588        assert!(registry.resolved_policy(&path).is_none());
589
590        registry.register(
591            CommandPolicy::new(path.clone())
592                .visibility(VisibilityMode::Authenticated)
593                .allow_profiles(["dev"])
594                .denied_message("sign in")
595                .hidden_reason("base hidden"),
596        );
597        assert!(registry.contains(&path));
598
599        registry.override_policy(
600            path.clone(),
601            CommandPolicyOverride {
602                visibility: Some(VisibilityMode::CapabilityGated),
603                availability: Some(CommandAvailability::Disabled),
604                required_capabilities: BTreeSet::from(["orch.policy.write".to_string()]),
605                hidden_reason: Some("override hidden".to_string()),
606                denied_message: Some("override denied".to_string()),
607            },
608        );
609
610        let resolved = registry
611            .resolved_policy(&path)
612            .expect("policy should resolve");
613        assert_eq!(resolved.visibility, VisibilityMode::CapabilityGated);
614        assert_eq!(resolved.availability, CommandAvailability::Disabled);
615        assert_eq!(
616            resolved.required_capabilities,
617            BTreeSet::from(["orch.policy.write".to_string()])
618        );
619        assert_eq!(resolved.hidden_reason.as_deref(), Some("override hidden"));
620        assert_eq!(resolved.denied_message.as_deref(), Some("override denied"));
621        assert_eq!(
622            registry.evaluate(&path, &CommandPolicyContext::default()),
623            Some(CommandAccess::hidden(AccessReason::DisabledByProduct))
624        );
625    }
626}