Skip to main content

auths_cli/commands/
policy.rs

1//! Policy governance commands for Auths.
2//!
3//! Commands for linting, compiling, testing, and comparing policies.
4
5use 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/// Manage authorization policies.
19#[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    /// Validate policy JSON syntax without full compilation.
45    Lint(LintCommand),
46
47    /// Compile a policy file with full validation.
48    Compile(CompileCommand),
49
50    /// Evaluate a policy against a context and show the decision.
51    Explain(ExplainCommand),
52
53    /// Run a policy against a test suite.
54    Test(TestCommand),
55
56    /// Compare two policies and show semantic differences.
57    Diff(DiffCommand),
58}
59
60/// Validate policy JSON syntax.
61#[derive(Parser, Debug, Clone)]
62pub struct LintCommand {
63    /// Path to the policy file (JSON).
64    pub file: PathBuf,
65}
66
67/// Compile a policy with full validation.
68#[derive(Parser, Debug, Clone)]
69pub struct CompileCommand {
70    /// Path to the policy file (JSON).
71    pub file: PathBuf,
72}
73
74/// Evaluate a policy against a context.
75#[derive(Parser, Debug, Clone)]
76pub struct ExplainCommand {
77    /// Path to the policy file (JSON).
78    pub file: PathBuf,
79
80    /// Path to the context file (JSON).
81    #[clap(long, short = 'c')]
82    pub context: PathBuf,
83}
84
85/// Run a policy against a test suite.
86#[derive(Parser, Debug, Clone)]
87pub struct TestCommand {
88    /// Path to the policy file (JSON).
89    pub file: PathBuf,
90
91    /// Path to the test suite file (JSON).
92    #[clap(long, short = 't')]
93    pub tests: PathBuf,
94}
95
96/// Compare two policies.
97#[derive(Parser, Debug, Clone)]
98pub struct DiffCommand {
99    /// Path to the old policy file (JSON).
100    pub old: PathBuf,
101
102    /// Path to the new policy file (JSON).
103    pub new: PathBuf,
104}
105
106// ── JSON Output Types ───────────────────────────────────────────────────
107
108#[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// ── Test Suite Types ────────────────────────────────────────────────────
166
167#[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<auths_policy::CanonicalCapability>,
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// ── Handler ─────────────────────────────────────────────────────────────
201
202#[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    // Read the file
219    let content =
220        fs::read(&cmd.file).with_context(|| format!("failed to read {}", cmd.file.display()))?;
221
222    let bytes = content.len();
223
224    // Check size limit
225    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    // Parse JSON
251    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    // Load and compile policy
359    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    // Load context
374    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    // Evaluate
383    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    // Load and compile policy
419    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    // Load test suite
434    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    // Parse both policy files (don't need full compilation for structural diff)
526    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
588// ── Helpers ─────────────────────────────────────────────────────────────
589
590fn 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        ctx = ctx.capability(cap.clone());
637    }
638
639    if let Some(role) = &test.role {
640        ctx = ctx.role(role.clone());
641    }
642
643    if let Some(exp) = test.expires_at {
644        ctx = ctx.expires_at(exp);
645    }
646
647    if let Some(ts) = test.timestamp {
648        ctx = ctx.timestamp(ts);
649    }
650
651    if let Some(repo) = &test.repo {
652        ctx = ctx.repo(repo.clone());
653    }
654
655    if let Some(git_ref) = &test.git_ref {
656        ctx = ctx.git_ref(git_ref.clone());
657    }
658
659    if !test.paths.is_empty() {
660        ctx = ctx.paths(test.paths.clone());
661    }
662
663    if let Some(env) = &test.environment {
664        ctx = ctx.environment(env.clone());
665    }
666
667    Ok(ctx)
668}
669
670fn normalize_outcome(s: &str) -> String {
671    match s.to_lowercase().as_str() {
672        "allow" => "Allow".into(),
673        "deny" => "Deny".into(),
674        "indeterminate" => "Indeterminate".into(),
675        _ => s.to_string(),
676    }
677}
678
679use crate::commands::executable::ExecutableCommand;
680use crate::config::CliConfig;
681
682impl ExecutableCommand for PolicyCommand {
683    fn execute(&self, _ctx: &CliConfig) -> Result<()> {
684        handle_policy(self.clone())
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use auths_sdk::workflows::policy_diff::{
692        PolicyChange, compute_policy_diff, overall_risk_score,
693    };
694
695    #[test]
696    fn test_normalize_outcome() {
697        assert_eq!(normalize_outcome("allow"), "Allow");
698        assert_eq!(normalize_outcome("Allow"), "Allow");
699        assert_eq!(normalize_outcome("ALLOW"), "Allow");
700        assert_eq!(normalize_outcome("deny"), "Deny");
701        assert_eq!(normalize_outcome("Deny"), "Deny");
702        assert_eq!(normalize_outcome("indeterminate"), "Indeterminate");
703    }
704
705    #[test]
706    fn test_overall_risk_score() {
707        let high = vec![PolicyChange {
708            kind: "removed".into(),
709            description: "NotRevoked".into(),
710            risk: "HIGH".into(),
711        }];
712        assert_eq!(overall_risk_score(&high), "HIGH");
713
714        let medium = vec![PolicyChange {
715            kind: "added".into(),
716            description: "HasCapability(sign)".into(),
717            risk: "MEDIUM".into(),
718        }];
719        assert_eq!(overall_risk_score(&medium), "MEDIUM");
720
721        let low = vec![PolicyChange {
722            kind: "added".into(),
723            description: "RepoIs(org/repo)".into(),
724            risk: "LOW".into(),
725        }];
726        assert_eq!(overall_risk_score(&low), "LOW");
727
728        assert_eq!(overall_risk_score(&[]), "LOW");
729    }
730
731    #[test]
732    fn test_collect_predicates_via_diff() {
733        let old = Expr::And(vec![Expr::NotRevoked, Expr::HasCapability("sign".into())]);
734        let new = Expr::And(vec![Expr::NotRevoked]);
735        let changes = compute_policy_diff(&old, &new);
736        assert!(
737            changes
738                .iter()
739                .any(|c| c.description.contains("HasCapability") && c.kind == "removed")
740        );
741    }
742
743    #[test]
744    fn test_structural_change_and_to_or() {
745        let old = Expr::And(vec![Expr::True]);
746        let new = Expr::Or(vec![Expr::True]);
747        let changes = compute_policy_diff(&old, &new);
748        let structural = changes.iter().find(|c| c.kind == "changed");
749        assert!(structural.is_some());
750        assert_eq!(structural.unwrap().risk, "HIGH");
751    }
752}