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}