1use crate::ux::format::{JsonResponse, Output, is_json_mode};
6use anyhow::{Context, Result, anyhow};
7use auths_policy::{
8 CompileError, CompiledExpr, EvalContext, Expr, Outcome, PolicyLimits,
9 compile_from_json_with_limits,
10};
11use auths_sdk::workflows::policy_diff::{compute_policy_diff, overall_risk_score};
12use chrono::{DateTime, Utc};
13use clap::{Parser, Subcommand};
14use serde::{Deserialize, Serialize};
15use std::fs;
16use std::path::PathBuf;
17
18#[derive(Parser, Debug, Clone)]
20#[command(
21 name = "policy",
22 about = "Manage authorization policies",
23 after_help = "Examples:
24 auths policy lint policy.json # Validate policy syntax
25 auths policy compile policy.json # Full compilation with validation
26 auths policy explain policy.json --context context.json
27 # Evaluate policy against context
28 auths policy test policy.json --tests test-suite.json
29 # Run policy against test cases
30 auths policy diff old-policy.json new-policy.json
31 # Compare policies and show diff
32
33Related:
34 auths approval — Manage approval gates
35 auths device — Check device capabilities"
36)]
37pub struct PolicyCommand {
38 #[command(subcommand)]
39 pub command: PolicySubcommand,
40}
41
42#[derive(Subcommand, Debug, Clone)]
43pub enum PolicySubcommand {
44 Lint(LintCommand),
46
47 Compile(CompileCommand),
49
50 Explain(ExplainCommand),
52
53 Test(TestCommand),
55
56 Diff(DiffCommand),
58}
59
60#[derive(Parser, Debug, Clone)]
62pub struct LintCommand {
63 pub file: PathBuf,
65}
66
67#[derive(Parser, Debug, Clone)]
69pub struct CompileCommand {
70 pub file: PathBuf,
72}
73
74#[derive(Parser, Debug, Clone)]
76pub struct ExplainCommand {
77 pub file: PathBuf,
79
80 #[clap(long, short = 'c')]
82 pub context: PathBuf,
83}
84
85#[derive(Parser, Debug, Clone)]
87pub struct TestCommand {
88 pub file: PathBuf,
90
91 #[clap(long, short = 't')]
93 pub tests: PathBuf,
94}
95
96#[derive(Parser, Debug, Clone)]
98pub struct DiffCommand {
99 pub old: PathBuf,
101
102 pub new: PathBuf,
104}
105
106#[derive(Debug, Serialize)]
109struct LintData {
110 bytes: usize,
111 byte_limit: usize,
112}
113
114#[derive(Debug, Serialize)]
115struct CompileData {
116 #[serde(skip_serializing_if = "Option::is_none")]
117 nodes: Option<u32>,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 depth: Option<u32>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 hash: Option<String>,
122 #[serde(skip_serializing_if = "Vec::is_empty")]
123 errors: Vec<String>,
124}
125
126#[derive(Debug, Serialize)]
127struct ExplainOutput {
128 decision: String,
129 reason_code: String,
130 message: String,
131 policy_hash: String,
132}
133
134#[derive(Debug, Serialize)]
135struct TestOutput {
136 passed: usize,
137 failed: usize,
138 total: usize,
139 results: Vec<TestResult>,
140}
141
142#[derive(Debug, Serialize)]
143struct TestResult {
144 name: String,
145 passed: bool,
146 expected: String,
147 actual: String,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 message: Option<String>,
150}
151
152#[derive(Debug, Serialize)]
153struct DiffOutput {
154 changes: Vec<DiffChange>,
155 risk_score: String,
156}
157
158#[derive(Debug, Serialize)]
159struct DiffChange {
160 kind: String,
161 description: String,
162 risk: String,
163}
164
165#[derive(Debug, Deserialize)]
168struct TestCase {
169 name: String,
170 context: TestContext,
171 expect: String,
172}
173
174#[derive(Debug, Deserialize)]
175struct TestContext {
176 issuer: String,
177 subject: String,
178 #[serde(default)]
179 revoked: bool,
180 #[serde(default)]
181 capabilities: Vec<String>,
182 #[serde(default)]
183 role: Option<String>,
184 #[serde(default)]
185 expires_at: Option<DateTime<Utc>>,
186 #[serde(default)]
187 timestamp: Option<DateTime<Utc>>,
188 #[serde(default)]
189 chain_depth: u32,
190 #[serde(default)]
191 repo: Option<String>,
192 #[serde(default)]
193 git_ref: Option<String>,
194 #[serde(default)]
195 paths: Vec<String>,
196 #[serde(default)]
197 environment: Option<String>,
198}
199
200#[allow(clippy::disallowed_methods)]
203pub fn handle_policy(cmd: PolicyCommand) -> Result<()> {
204 let now = Utc::now();
205 match cmd.command {
206 PolicySubcommand::Lint(lint) => handle_lint(lint),
207 PolicySubcommand::Compile(compile) => handle_compile(compile),
208 PolicySubcommand::Explain(explain) => handle_explain(explain, now),
209 PolicySubcommand::Test(test) => handle_test(test, now),
210 PolicySubcommand::Diff(diff) => handle_diff(diff),
211 }
212}
213
214fn handle_lint(cmd: LintCommand) -> Result<()> {
215 let out = Output::new();
216 let limits = PolicyLimits::default();
217
218 let content =
220 fs::read(&cmd.file).with_context(|| format!("failed to read {}", cmd.file.display()))?;
221
222 let bytes = content.len();
223
224 if bytes > limits.max_json_bytes {
226 if is_json_mode() {
227 JsonResponse::<()>::error(
228 "policy lint",
229 format!(
230 "file exceeds size limit: {} > {}",
231 bytes, limits.max_json_bytes
232 ),
233 )
234 .print()?;
235 } else {
236 out.println(&format!(
237 "{} File exceeds size limit: {} bytes (limit: {})",
238 out.error("x"),
239 bytes,
240 limits.max_json_bytes
241 ));
242 }
243 anyhow::bail!(
244 "file exceeds size limit: {} > {}",
245 bytes,
246 limits.max_json_bytes
247 );
248 }
249
250 match serde_json::from_slice::<Expr>(&content) {
252 Ok(_expr) => {
253 if is_json_mode() {
254 JsonResponse::success(
255 "policy lint",
256 LintData {
257 bytes,
258 byte_limit: limits.max_json_bytes,
259 },
260 )
261 .print()?;
262 } else {
263 out.println(&format!("{} Valid JSON", out.success("ok")));
264 out.println(&format!("{} All ops recognized", out.success("ok")));
265 out.println(&format!(
266 "{} {} bytes (limit: {})",
267 out.success("ok"),
268 bytes,
269 limits.max_json_bytes
270 ));
271 }
272 }
273 Err(e) => {
274 if is_json_mode() {
275 JsonResponse::<()>::error("policy lint", e.to_string()).print()?;
276 } else {
277 out.println(&format!("{} Invalid JSON: {}", out.error("x"), e));
278 }
279 anyhow::bail!("lint failed: {}", e);
280 }
281 }
282
283 Ok(())
284}
285
286fn handle_compile(cmd: CompileCommand) -> Result<()> {
287 let out = Output::new();
288 let limits = PolicyLimits::default();
289
290 let content =
291 fs::read(&cmd.file).with_context(|| format!("failed to read {}", cmd.file.display()))?;
292
293 match compile_from_json_with_limits(&content, &limits) {
294 Ok(policy) => {
295 let stats = compute_policy_stats(policy.expr());
296 let hash = hex::encode(policy.source_hash());
297
298 if is_json_mode() {
299 JsonResponse::success(
300 "policy compile",
301 CompileData {
302 nodes: Some(stats.nodes),
303 depth: Some(stats.depth),
304 hash: Some(hash),
305 errors: vec![],
306 },
307 )
308 .print()?;
309 } else {
310 out.println(&format!("{} Compiled successfully", out.success("ok")));
311 out.println(&format!(
312 " Nodes: {} (limit: {})",
313 stats.nodes, limits.max_total_nodes
314 ));
315 out.println(&format!(
316 " Depth: {} (limit: {})",
317 stats.depth, limits.max_depth
318 ));
319 out.println(&format!(" Hash: {}", hash));
320 }
321 }
322 Err(errors) => {
323 let error_strs: Vec<String> = errors.iter().map(format_compile_error).collect();
324
325 if is_json_mode() {
326 JsonResponse {
327 success: false,
328 command: "policy compile".to_string(),
329 data: Some(CompileData {
330 nodes: None,
331 depth: None,
332 hash: None,
333 errors: error_strs,
334 }),
335 error: None,
336 }
337 .print()?;
338 } else {
339 out.println(&format!(
340 "{} Compilation failed ({} errors):",
341 out.error("x"),
342 errors.len()
343 ));
344 for error in &error_strs {
345 out.println(&format!(" {}", error));
346 }
347 }
348 }
349 }
350
351 Ok(())
352}
353
354fn handle_explain(cmd: ExplainCommand, now: DateTime<Utc>) -> Result<()> {
355 let out = Output::new();
356 let limits = PolicyLimits::default();
357
358 let policy_content = fs::read(&cmd.file)
360 .with_context(|| format!("failed to read policy: {}", cmd.file.display()))?;
361
362 let policy = compile_from_json_with_limits(&policy_content, &limits).map_err(|errors| {
363 anyhow!(
364 "policy compilation failed: {}",
365 errors
366 .iter()
367 .map(format_compile_error)
368 .collect::<Vec<_>>()
369 .join("; ")
370 )
371 })?;
372
373 let ctx_content = fs::read(&cmd.context)
375 .with_context(|| format!("failed to read context: {}", cmd.context.display()))?;
376
377 let test_ctx: TestContext =
378 serde_json::from_slice(&ctx_content).with_context(|| "failed to parse context JSON")?;
379
380 let eval_ctx = build_eval_context(&test_ctx, now)?;
381
382 let decision = auths_policy::evaluate3(&policy, &eval_ctx);
384 let hash = hex::encode(policy.source_hash());
385
386 if is_json_mode() {
387 JsonResponse::success(
388 "policy explain",
389 ExplainOutput {
390 decision: format!("{:?}", decision.outcome),
391 reason_code: format!("{:?}", decision.reason),
392 message: decision.message.clone(),
393 policy_hash: hash,
394 },
395 )
396 .print()?;
397 } else {
398 let decision_str = match decision.outcome {
399 Outcome::Allow => out.success("ALLOW"),
400 Outcome::Deny => out.error("DENY"),
401 Outcome::Indeterminate => out.warn("INDETERMINATE"),
402 Outcome::RequiresApproval => out.warn("REQUIRES_APPROVAL"),
403 _ => out.warn(&format!("{:?}", decision.outcome)),
404 };
405 out.println(&format!("Decision: {}", decision_str));
406 out.println(&format!(" Reason: {:?}", decision.reason));
407 out.println(&format!(" Message: {}", decision.message));
408 out.println(&format!("Policy hash: {}", hash));
409 }
410
411 Ok(())
412}
413
414fn handle_test(cmd: TestCommand, now: DateTime<Utc>) -> Result<()> {
415 let out = Output::new();
416 let limits = PolicyLimits::default();
417
418 let policy_content = fs::read(&cmd.file)
420 .with_context(|| format!("failed to read policy: {}", cmd.file.display()))?;
421
422 let policy = compile_from_json_with_limits(&policy_content, &limits).map_err(|errors| {
423 anyhow!(
424 "policy compilation failed: {}",
425 errors
426 .iter()
427 .map(format_compile_error)
428 .collect::<Vec<_>>()
429 .join("; ")
430 )
431 })?;
432
433 let tests_content = fs::read(&cmd.tests)
435 .with_context(|| format!("failed to read tests: {}", cmd.tests.display()))?;
436
437 let test_cases: Vec<TestCase> = serde_json::from_slice(&tests_content)
438 .with_context(|| "failed to parse test suite JSON")?;
439
440 let mut results: Vec<TestResult> = Vec::new();
441 let mut passed = 0;
442 let mut failed = 0;
443
444 for test in test_cases {
445 let eval_ctx = match build_eval_context(&test.context, now) {
446 Ok(ctx) => ctx,
447 Err(e) => {
448 results.push(TestResult {
449 name: test.name.clone(),
450 passed: false,
451 expected: test.expect.clone(),
452 actual: "ERROR".into(),
453 message: Some(e.to_string()),
454 });
455 failed += 1;
456 continue;
457 }
458 };
459
460 let decision = auths_policy::evaluate3(&policy, &eval_ctx);
461 let actual = format!("{:?}", decision.outcome);
462 let expected_normalized = normalize_outcome(&test.expect);
463 let test_passed = actual == expected_normalized;
464
465 if test_passed {
466 passed += 1;
467 } else {
468 failed += 1;
469 }
470
471 results.push(TestResult {
472 name: test.name,
473 passed: test_passed,
474 expected: expected_normalized,
475 actual,
476 message: if test_passed {
477 None
478 } else {
479 Some(decision.message.clone())
480 },
481 });
482 }
483
484 let total = passed + failed;
485
486 if is_json_mode() {
487 JsonResponse::success(
488 "policy test",
489 TestOutput {
490 passed,
491 failed,
492 total,
493 results,
494 },
495 )
496 .print()?;
497 } else {
498 for result in &results {
499 let status = if result.passed {
500 out.success("ok")
501 } else {
502 out.error("FAIL")
503 };
504 out.println(&format!(
505 " {} {}: {} (expected {})",
506 status, result.name, result.actual, result.expected
507 ));
508 if let Some(msg) = &result.message {
509 out.println(&format!(" {}", out.dim(msg)));
510 }
511 }
512 out.println(&format!("{}/{} passed", passed, total));
513 }
514
515 if failed > 0 {
516 anyhow::bail!("{} test(s) failed", failed);
517 }
518
519 Ok(())
520}
521
522fn handle_diff(cmd: DiffCommand) -> Result<()> {
523 let out = Output::new();
524
525 let old_content = fs::read(&cmd.old)
527 .with_context(|| format!("failed to read old policy: {}", cmd.old.display()))?;
528 let new_content = fs::read(&cmd.new)
529 .with_context(|| format!("failed to read new policy: {}", cmd.new.display()))?;
530
531 let old_expr: Expr =
532 serde_json::from_slice(&old_content).with_context(|| "failed to parse old policy JSON")?;
533 let new_expr: Expr =
534 serde_json::from_slice(&new_content).with_context(|| "failed to parse new policy JSON")?;
535
536 let changes = compute_policy_diff(&old_expr, &new_expr);
537 let risk_score = overall_risk_score(&changes);
538
539 if is_json_mode() {
540 JsonResponse::success(
541 "policy diff",
542 DiffOutput {
543 changes: changes
544 .iter()
545 .map(|c| DiffChange {
546 kind: c.kind.clone(),
547 description: c.description.clone(),
548 risk: c.risk.clone(),
549 })
550 .collect(),
551 risk_score: risk_score.clone(),
552 },
553 )
554 .print()?;
555 } else if changes.is_empty() {
556 out.println("No changes detected");
557 } else {
558 out.println("Changes:");
559 for change in &changes {
560 let risk_marker = match change.risk.as_str() {
561 "HIGH" => out.error("HIGH RISK"),
562 "MEDIUM" => out.warn("MEDIUM"),
563 _ => out.dim("LOW"),
564 };
565 let kind_marker = match change.kind.as_str() {
566 "added" => "+",
567 "removed" => "-",
568 "changed" => "~",
569 _ => "?",
570 };
571 out.println(&format!(
572 " {} {}: {} [{}]",
573 kind_marker, change.description, risk_marker, change.risk
574 ));
575 }
576 out.println("");
577 let risk_display = match risk_score.as_str() {
578 "HIGH" => out.error(&risk_score),
579 "MEDIUM" => out.warn(&risk_score),
580 _ => out.dim(&risk_score),
581 };
582 out.println(&format!("Risk score: {}", risk_display));
583 }
584
585 Ok(())
586}
587
588fn format_compile_error(error: &CompileError) -> String {
591 format!("at {}: {}", error.path, error.message)
592}
593
594struct PolicyStats {
595 nodes: u32,
596 depth: u32,
597}
598
599fn compute_policy_stats(expr: &CompiledExpr) -> PolicyStats {
600 fn count_nodes(expr: &CompiledExpr) -> u32 {
601 match expr {
602 CompiledExpr::True | CompiledExpr::False => 1,
603 CompiledExpr::And(children) | CompiledExpr::Or(children) => {
604 1 + children.iter().map(count_nodes).sum::<u32>()
605 }
606 CompiledExpr::Not(inner) => 1 + count_nodes(inner),
607 _ => 1,
608 }
609 }
610
611 fn compute_depth(expr: &CompiledExpr) -> u32 {
612 match expr {
613 CompiledExpr::True | CompiledExpr::False => 1,
614 CompiledExpr::And(children) | CompiledExpr::Or(children) => {
615 1 + children.iter().map(compute_depth).max().unwrap_or(0)
616 }
617 CompiledExpr::Not(inner) => 1 + compute_depth(inner),
618 _ => 1,
619 }
620 }
621
622 PolicyStats {
623 nodes: count_nodes(expr),
624 depth: compute_depth(expr),
625 }
626}
627
628fn build_eval_context(test: &TestContext, now: DateTime<Utc>) -> Result<EvalContext> {
629 let mut ctx = EvalContext::try_from_strings(now, &test.issuer, &test.subject)
630 .map_err(|e| anyhow!("Invalid identity format: {}", e))?;
631
632 ctx = ctx.revoked(test.revoked);
633 ctx = ctx.chain_depth(test.chain_depth);
634
635 for cap in &test.capabilities {
636 let canonical = auths_policy::CanonicalCapability::parse(cap)
637 .map_err(|e| anyhow!("invalid capability '{}': {}", cap, e))?;
638 ctx = ctx.capability(canonical);
639 }
640
641 if let Some(role) = &test.role {
642 ctx = ctx.role(role.clone());
643 }
644
645 if let Some(exp) = test.expires_at {
646 ctx = ctx.expires_at(exp);
647 }
648
649 if let Some(ts) = test.timestamp {
650 ctx = ctx.timestamp(ts);
651 }
652
653 if let Some(repo) = &test.repo {
654 ctx = ctx.repo(repo.clone());
655 }
656
657 if let Some(git_ref) = &test.git_ref {
658 ctx = ctx.git_ref(git_ref.clone());
659 }
660
661 if !test.paths.is_empty() {
662 ctx = ctx.paths(test.paths.clone());
663 }
664
665 if let Some(env) = &test.environment {
666 ctx = ctx.environment(env.clone());
667 }
668
669 Ok(ctx)
670}
671
672fn normalize_outcome(s: &str) -> String {
673 match s.to_lowercase().as_str() {
674 "allow" => "Allow".into(),
675 "deny" => "Deny".into(),
676 "indeterminate" => "Indeterminate".into(),
677 _ => s.to_string(),
678 }
679}
680
681use crate::commands::executable::ExecutableCommand;
682use crate::config::CliConfig;
683
684impl ExecutableCommand for PolicyCommand {
685 fn execute(&self, _ctx: &CliConfig) -> Result<()> {
686 handle_policy(self.clone())
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use auths_sdk::workflows::policy_diff::{
694 PolicyChange, compute_policy_diff, overall_risk_score,
695 };
696
697 #[test]
698 fn test_normalize_outcome() {
699 assert_eq!(normalize_outcome("allow"), "Allow");
700 assert_eq!(normalize_outcome("Allow"), "Allow");
701 assert_eq!(normalize_outcome("ALLOW"), "Allow");
702 assert_eq!(normalize_outcome("deny"), "Deny");
703 assert_eq!(normalize_outcome("Deny"), "Deny");
704 assert_eq!(normalize_outcome("indeterminate"), "Indeterminate");
705 }
706
707 #[test]
708 fn test_overall_risk_score() {
709 let high = vec![PolicyChange {
710 kind: "removed".into(),
711 description: "NotRevoked".into(),
712 risk: "HIGH".into(),
713 }];
714 assert_eq!(overall_risk_score(&high), "HIGH");
715
716 let medium = vec![PolicyChange {
717 kind: "added".into(),
718 description: "HasCapability(sign)".into(),
719 risk: "MEDIUM".into(),
720 }];
721 assert_eq!(overall_risk_score(&medium), "MEDIUM");
722
723 let low = vec![PolicyChange {
724 kind: "added".into(),
725 description: "RepoIs(org/repo)".into(),
726 risk: "LOW".into(),
727 }];
728 assert_eq!(overall_risk_score(&low), "LOW");
729
730 assert_eq!(overall_risk_score(&[]), "LOW");
731 }
732
733 #[test]
734 fn test_collect_predicates_via_diff() {
735 let old = Expr::And(vec![Expr::NotRevoked, Expr::HasCapability("sign".into())]);
736 let new = Expr::And(vec![Expr::NotRevoked]);
737 let changes = compute_policy_diff(&old, &new);
738 assert!(
739 changes
740 .iter()
741 .any(|c| c.description.contains("HasCapability") && c.kind == "removed")
742 );
743 }
744
745 #[test]
746 fn test_structural_change_and_to_or() {
747 let old = Expr::And(vec![Expr::True]);
748 let new = Expr::Or(vec![Expr::True]);
749 let changes = compute_policy_diff(&old, &new);
750 let structural = changes.iter().find(|c| c.kind == "changed");
751 assert!(structural.is_some());
752 assert_eq!(structural.unwrap().risk, "HIGH");
753 }
754}