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}