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    pub fn entries(&self) -> impl Iterator<Item = &CommandPolicy> {
307        self.entries.values()
308    }
309}
310
311pub fn evaluate_policy(policy: &CommandPolicy, context: &CommandPolicyContext) -> CommandAccess {
312    if matches!(policy.availability, CommandAvailability::Disabled) {
313        return CommandAccess::hidden(AccessReason::DisabledByProduct);
314    }
315    if matches!(policy.visibility, VisibilityMode::Hidden) {
316        return CommandAccess::hidden(AccessReason::HiddenByPolicy);
317    }
318    if let Some(allowed_profiles) = &policy.allowed_profiles {
319        match context.active_profile.as_ref() {
320            Some(profile) if allowed_profiles.contains(profile) => {}
321            Some(profile) => {
322                return CommandAccess::hidden(AccessReason::ProfileUnavailable(profile.clone()));
323            }
324            None => return CommandAccess::hidden(AccessReason::ProfileUnavailable(String::new())),
325        }
326    }
327    if let Some(feature) = policy
328        .feature_flags
329        .iter()
330        .find(|feature| !context.enabled_features.contains(*feature))
331    {
332        return CommandAccess::hidden(AccessReason::FeatureDisabled(feature.clone()));
333    }
334
335    match policy.visibility {
336        VisibilityMode::Public => CommandAccess::visible_runnable(),
337        VisibilityMode::Authenticated => {
338            if context.authenticated {
339                CommandAccess::visible_runnable()
340            } else {
341                CommandAccess::visible_denied(AccessReason::Unauthenticated)
342            }
343        }
344        VisibilityMode::CapabilityGated => {
345            if !context.authenticated {
346                return CommandAccess::visible_denied(AccessReason::Unauthenticated);
347            }
348            let missing = policy
349                .required_capabilities
350                .iter()
351                .filter(|capability| !context.capabilities.contains(*capability))
352                .cloned()
353                .collect::<BTreeSet<_>>();
354            if missing.is_empty() {
355                CommandAccess::visible_runnable()
356            } else {
357                CommandAccess {
358                    visibility: CommandVisibility::Visible,
359                    runnable: CommandRunnable::Denied,
360                    reasons: vec![AccessReason::MissingCapabilities],
361                    missing_capabilities: missing,
362                }
363            }
364        }
365        VisibilityMode::Hidden => CommandAccess::hidden(AccessReason::HiddenByPolicy),
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use std::collections::BTreeSet;
372
373    use super::{
374        AccessReason, CommandAccess, CommandAvailability, CommandPath, CommandPolicy,
375        CommandPolicyContext, CommandPolicyOverride, CommandPolicyRegistry, CommandRunnable,
376        CommandVisibility, VisibilityMode, evaluate_policy,
377    };
378
379    #[test]
380    fn command_path_and_policy_builders_normalize_inputs() {
381        let path = CommandPath::new([" Orch ", "", "Approval", "  Decide  "]);
382        assert_eq!(
383            path.as_slice(),
384            &[
385                "orch".to_string(),
386                "approval".to_string(),
387                "decide".to_string()
388            ]
389        );
390        assert!(!path.is_empty());
391        assert!(CommandPath::new(["", "   "]).is_empty());
392
393        let policy = CommandPolicy::new(path.clone())
394            .visibility(VisibilityMode::CapabilityGated)
395            .require_capability(" Orch.Approval.Decide ")
396            .require_capability("   ")
397            .feature_flag(" Orch ")
398            .feature_flag("")
399            .allow_profiles([" Dev ", " ", "Prod"])
400            .denied_message("  Sign in first  ")
401            .hidden_reason("  hidden upstream  ");
402
403        assert_eq!(policy.path, path);
404        assert_eq!(policy.visibility, VisibilityMode::CapabilityGated);
405        assert_eq!(
406            policy.required_capabilities,
407            BTreeSet::from(["orch.approval.decide".to_string()])
408        );
409        assert_eq!(policy.feature_flags, BTreeSet::from(["orch".to_string()]));
410        assert_eq!(
411            policy.allowed_profiles,
412            Some(BTreeSet::from(["dev".to_string(), "prod".to_string()]))
413        );
414        assert_eq!(policy.denied_message.as_deref(), Some("Sign in first"));
415        assert_eq!(policy.hidden_reason.as_deref(), Some("hidden upstream"));
416    }
417
418    #[test]
419    fn policy_context_builders_normalize_inputs() {
420        let context = CommandPolicyContext::default()
421            .authenticated(true)
422            .with_capabilities([" Orch.Read ", "", "orch.write"])
423            .with_features([" Orch ", " "])
424            .with_profile(" Dev ");
425
426        assert!(context.authenticated);
427        assert_eq!(
428            context.capabilities,
429            BTreeSet::from(["orch.read".to_string(), "orch.write".to_string()])
430        );
431        assert_eq!(
432            context.enabled_features,
433            BTreeSet::from(["orch".to_string()])
434        );
435        assert_eq!(context.active_profile.as_deref(), Some("dev"));
436        assert_eq!(
437            CommandPolicyContext::default()
438                .with_profile("   ")
439                .active_profile,
440            None
441        );
442    }
443
444    #[test]
445    fn capability_gated_command_is_visible_but_denied_when_capability_missing() {
446        let mut registry = CommandPolicyRegistry::new();
447        let path = CommandPath::new(["orch", "approval", "decide"]);
448        registry.register(
449            CommandPolicy::new(path.clone())
450                .visibility(VisibilityMode::CapabilityGated)
451                .require_capability("orch.approval.decide"),
452        );
453
454        let access = registry
455            .evaluate(&path, &CommandPolicyContext::default().authenticated(true))
456            .expect("policy should exist");
457
458        assert_eq!(access.visibility, CommandVisibility::Visible);
459        assert_eq!(access.runnable, CommandRunnable::Denied);
460        assert_eq!(access.reasons, vec![AccessReason::MissingCapabilities]);
461    }
462
463    #[test]
464    fn required_capabilities_are_simple_conjunction() {
465        let mut registry = CommandPolicyRegistry::new();
466        let path = CommandPath::new(["orch", "policy", "add"]);
467        registry.register(
468            CommandPolicy::new(path.clone())
469                .visibility(VisibilityMode::CapabilityGated)
470                .require_capability("orch.policy.read")
471                .require_capability("orch.policy.write"),
472        );
473
474        let access = registry
475            .evaluate(
476                &path,
477                &CommandPolicyContext::default()
478                    .authenticated(true)
479                    .with_capabilities(["orch.policy.read"]),
480            )
481            .expect("policy should exist");
482
483        assert!(access.missing_capabilities.contains("orch.policy.write"));
484    }
485
486    #[test]
487    fn public_commands_can_remain_unauthenticated() {
488        let policy = CommandPolicy::new(CommandPath::new(["help"]));
489        let access = evaluate_policy(&policy, &CommandPolicyContext::default());
490        assert_eq!(access, CommandAccess::visible_runnable());
491    }
492
493    #[test]
494    fn overrides_can_hide_commands() {
495        let mut registry = CommandPolicyRegistry::new();
496        let path = CommandPath::new(["nh", "audit"]);
497        registry.register(CommandPolicy::new(path.clone()));
498        registry.override_policy(
499            path.clone(),
500            CommandPolicyOverride {
501                visibility: Some(VisibilityMode::Hidden),
502                ..CommandPolicyOverride::default()
503            },
504        );
505
506        let access = registry
507            .evaluate(&path, &CommandPolicyContext::default())
508            .expect("policy should exist");
509        assert_eq!(access.visibility, CommandVisibility::Hidden);
510    }
511
512    #[test]
513    fn access_helpers_reflect_visibility_and_runnability() {
514        let access = CommandAccess::visible_denied(AccessReason::Unauthenticated);
515        assert!(access.is_visible());
516        assert!(!access.is_runnable());
517    }
518
519    #[test]
520    fn evaluate_policy_covers_disabled_hidden_feature_profile_and_auth_variants() {
521        let disabled = CommandPolicy::new(CommandPath::new(["orch"]))
522            .visibility(VisibilityMode::Authenticated);
523        let mut disabled = disabled;
524        disabled.availability = CommandAvailability::Disabled;
525        assert_eq!(
526            evaluate_policy(&disabled, &CommandPolicyContext::default()),
527            CommandAccess::hidden(AccessReason::DisabledByProduct)
528        );
529
530        let hidden =
531            CommandPolicy::new(CommandPath::new(["orch"])).visibility(VisibilityMode::Hidden);
532        assert_eq!(
533            evaluate_policy(&hidden, &CommandPolicyContext::default()),
534            CommandAccess::hidden(AccessReason::HiddenByPolicy)
535        );
536
537        let profiled = CommandPolicy::new(CommandPath::new(["orch"]))
538            .allow_profiles(["dev"])
539            .feature_flag("orch");
540        assert_eq!(
541            evaluate_policy(&profiled, &CommandPolicyContext::default()),
542            CommandAccess::hidden(AccessReason::ProfileUnavailable(String::new()))
543        );
544        assert_eq!(
545            evaluate_policy(
546                &profiled,
547                &CommandPolicyContext::default().with_profile("prod")
548            ),
549            CommandAccess::hidden(AccessReason::ProfileUnavailable("prod".to_string()))
550        );
551        assert_eq!(
552            evaluate_policy(
553                &profiled,
554                &CommandPolicyContext::default().with_profile("dev")
555            ),
556            CommandAccess::hidden(AccessReason::FeatureDisabled("orch".to_string()))
557        );
558
559        let auth_only = CommandPolicy::new(CommandPath::new(["auth", "status"]))
560            .visibility(VisibilityMode::Authenticated);
561        assert_eq!(
562            evaluate_policy(&auth_only, &CommandPolicyContext::default()),
563            CommandAccess::visible_denied(AccessReason::Unauthenticated)
564        );
565        assert_eq!(
566            evaluate_policy(
567                &auth_only,
568                &CommandPolicyContext::default().authenticated(true)
569            ),
570            CommandAccess::visible_runnable()
571        );
572
573        let capability = CommandPolicy::new(CommandPath::new(["orch", "approval"]))
574            .visibility(VisibilityMode::CapabilityGated)
575            .require_capability("orch.approval.decide");
576        assert_eq!(
577            evaluate_policy(
578                &capability,
579                &CommandPolicyContext::default()
580                    .authenticated(true)
581                    .with_capabilities(["orch.approval.decide"])
582            ),
583            CommandAccess::visible_runnable()
584        );
585    }
586
587    #[test]
588    fn registry_resolution_applies_overrides_and_contains_lookup() {
589        let path = CommandPath::new(["orch", "policy"]);
590        let mut registry = CommandPolicyRegistry::new();
591        assert!(!registry.contains(&path));
592        assert!(registry.resolved_policy(&path).is_none());
593
594        registry.register(
595            CommandPolicy::new(path.clone())
596                .visibility(VisibilityMode::Authenticated)
597                .allow_profiles(["dev"])
598                .denied_message("sign in")
599                .hidden_reason("base hidden"),
600        );
601        assert!(registry.contains(&path));
602
603        registry.override_policy(
604            path.clone(),
605            CommandPolicyOverride {
606                visibility: Some(VisibilityMode::CapabilityGated),
607                availability: Some(CommandAvailability::Disabled),
608                required_capabilities: BTreeSet::from(["orch.policy.write".to_string()]),
609                hidden_reason: Some("override hidden".to_string()),
610                denied_message: Some("override denied".to_string()),
611            },
612        );
613
614        let resolved = registry
615            .resolved_policy(&path)
616            .expect("policy should resolve");
617        assert_eq!(resolved.visibility, VisibilityMode::CapabilityGated);
618        assert_eq!(resolved.availability, CommandAvailability::Disabled);
619        assert_eq!(
620            resolved.required_capabilities,
621            BTreeSet::from(["orch.policy.write".to_string()])
622        );
623        assert_eq!(resolved.hidden_reason.as_deref(), Some("override hidden"));
624        assert_eq!(resolved.denied_message.as_deref(), Some("override denied"));
625        assert_eq!(
626            registry.evaluate(&path, &CommandPolicyContext::default()),
627            Some(CommandAccess::hidden(AccessReason::DisabledByProduct))
628        );
629    }
630}