Skip to main content

auths_cli/commands/
compliance.rs

1//! `auths compliance` — compliance-as-a-query evidence packs.
2//!
3//! Thin presentation layer over [`auths_sdk::workflows::compliance`]: it reads the
4//! period's releases, calls the SDK query engine to classify each signer's authority
5//! **at release** (by KEL position), embeds the honest witness verdict, optionally
6//! embeds the org KEL bundle for offline verification, and optionally org-signs the
7//! pack as a DSSE-wrapped in-toto statement. No domain logic lives here.
8
9use anyhow::{Context, Result, anyhow};
10use chrono::{DateTime, Utc};
11use clap::{Parser, Subcommand, ValueEnum};
12use std::fs;
13use std::path::PathBuf;
14
15use auths_crypto::CurveType;
16use auths_sdk::context::AuthsContext;
17use auths_sdk::keychain::{KeyAlias, extract_public_key_bytes};
18use auths_sdk::storage_layout::layout;
19use auths_sdk::workflows::compliance::{
20    ComplianceFramework, ReleaseRecord, TransparencyInclusion, VsaParams, build_evidence_pack,
21    build_framework_report, build_offline_evidence_pack, load_witness_policy, sign_evidence_pack,
22    sign_framework_report,
23};
24use auths_verifier::{IdentityDID, Prefix};
25
26use crate::commands::executable::ExecutableCommand;
27use crate::config::CliConfig;
28use crate::factories::storage::build_auths_context;
29
30/// Default keychain alias for an org's signing key (`org-{slug}`), matching the
31/// `auths org` convention.
32fn org_slug_alias(org: &str) -> String {
33    format!(
34        "org-{}",
35        org.chars()
36            .filter(|c| c.is_alphanumeric())
37            .take(20)
38            .collect::<String>()
39            .to_lowercase()
40    )
41}
42
43/// Resolve the org signing alias (defaulting to the slug alias) and its in-band curve.
44fn resolve_org_signing(
45    sdk_ctx: &AuthsContext,
46    org: &str,
47    key: Option<String>,
48) -> Result<(KeyAlias, CurveType)> {
49    let org_alias = KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(org)));
50    let (_pk, curve) = extract_public_key_bytes(
51        sdk_ctx.key_storage.as_ref(),
52        &org_alias,
53        sdk_ctx.passphrase_provider.as_ref(),
54    )
55    .with_context(|| format!("Failed to resolve org signing key '{org_alias}'"))?;
56    Ok((org_alias, curve))
57}
58
59/// One release entry in the `--releases` JSON file. `signer` accepts a `did:keri:`
60/// or a bare KEL prefix; `transparency` is the optional log inclusion evidence.
61#[derive(Debug, Clone, serde::Deserialize)]
62struct ReleaseInput {
63    artifact_digest: String,
64    signer: String,
65    #[serde(default)]
66    signed_at: Option<u128>,
67    #[serde(default)]
68    transparency: Option<TransparencyInclusion>,
69}
70
71impl ReleaseInput {
72    fn into_record(self) -> ReleaseRecord {
73        let signer_prefix = Prefix::new_unchecked(
74            self.signer
75                .strip_prefix("did:keri:")
76                .unwrap_or(&self.signer)
77                .to_string(),
78        );
79        ReleaseRecord {
80            artifact_digest: self.artifact_digest,
81            signer_prefix,
82            signed_at: self.signed_at,
83            transparency: self.transparency,
84        }
85    }
86}
87
88/// CLI wrapper for the target compliance framework.
89#[derive(Debug, Clone, Copy, ValueEnum)]
90pub enum CliFramework {
91    /// SLSA provenance.
92    Slsa,
93    /// SPDX software bill of materials.
94    Sbom,
95    /// EU Cyber Resilience Act obligation mapping.
96    Cra,
97}
98
99impl From<CliFramework> for ComplianceFramework {
100    fn from(f: CliFramework) -> Self {
101        match f {
102            CliFramework::Slsa => ComplianceFramework::Slsa,
103            CliFramework::Sbom => ComplianceFramework::Sbom,
104            CliFramework::Cra => ComplianceFramework::Cra,
105        }
106    }
107}
108
109/// The `compliance` subcommand: compliance as a query over the org's event log.
110#[derive(Parser, Debug, Clone)]
111#[command(
112    about = "Compliance as a query — offline-verifiable evidence packs",
113    after_help = "Examples:
114  auths compliance report --org did:keri:EOrg --period 2026-Q3 \\
115      --framework slsa --releases releases.json --offline --out acme-2026Q3.evidence
116                        # Build an offline-verifiable evidence pack
117
118Releases file (JSON array):
119  [{\"artifact_digest\":\"sha256:…\",\"signer\":\"did:keri:EMember\",\"signed_at\":41}]"
120)]
121pub struct ComplianceCommand {
122    #[clap(subcommand)]
123    pub subcommand: ComplianceSubcommand,
124}
125
126/// Subcommands for compliance queries.
127#[derive(Subcommand, Debug, Clone)]
128pub enum ComplianceSubcommand {
129    /// Produce a compliance evidence pack for a reporting period.
130    Report {
131        /// Organization identity ID (`did:keri:…`) or bare prefix
132        #[arg(long)]
133        org: String,
134
135        /// Reporting period label (free-form, e.g. `2026-Q3`)
136        #[arg(long)]
137        period: String,
138
139        /// Target framework (tags the pack; with `--predicate`, selects the
140        /// rendered predicate: SLSA provenance+VSA / SPDX SBOM / CRA mapping)
141        #[arg(long, value_enum, default_value = "slsa")]
142        framework: CliFramework,
143
144        /// Render the framework predicate (in-toto Statement) instead of the raw pack
145        #[arg(long)]
146        predicate: bool,
147
148        /// Verifier id recorded in the SLSA VSA (with `--predicate --framework slsa`)
149        #[arg(long, default_value = "https://auths.dev/compliance")]
150        verifier_id: String,
151
152        /// JSON file: array of `{ artifact_digest, signer, signed_at?, transparency? }`
153        #[arg(long)]
154        releases: PathBuf,
155
156        /// Embed the org KEL bundle so each row verifies offline (no network)
157        #[arg(long)]
158        offline: bool,
159
160        /// Org-sign the pack as a DSSE-wrapped in-toto statement
161        #[arg(long)]
162        sign: bool,
163
164        /// Org signing key alias (defaults to the org slug alias); used with `--sign`
165        #[arg(long)]
166        key: Option<String>,
167
168        /// Pinned witness-policy path (default: `$AUTHS_WITNESS_POLICY_PATH`, else fail-closed)
169        #[arg(long)]
170        witness_policy: Option<PathBuf>,
171
172        /// Output file (default: stdout)
173        #[arg(long)]
174        out: Option<PathBuf>,
175    },
176}
177
178/// Handle `auths compliance` subcommands.
179///
180/// Args:
181/// * `cmd`: The parsed compliance command.
182/// * `ctx`: The CLI config (repo path, passphrase provider, env).
183/// * `now`: The presentation-boundary timestamp (injected into the pack).
184///
185/// Usage:
186/// ```ignore
187/// handle_compliance(cmd, ctx, Utc::now())?;
188/// ```
189pub fn handle_compliance(
190    cmd: ComplianceCommand,
191    ctx: &CliConfig,
192    now: DateTime<Utc>,
193) -> Result<()> {
194    match cmd.subcommand {
195        ComplianceSubcommand::Report {
196            org,
197            period,
198            framework,
199            predicate,
200            verifier_id,
201            releases,
202            offline,
203            sign,
204            key,
205            witness_policy,
206            out,
207        } => {
208            let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?;
209            let passphrase_provider = ctx.passphrase_provider.clone();
210
211            let org_prefix =
212                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
213            let org_did = IdentityDID::from_prefix(org_prefix.as_str())
214                .map_err(|e| anyhow!("invalid org identifier '{org}': {e}"))?;
215
216            let raw = fs::read_to_string(&releases)
217                .with_context(|| format!("Failed to read releases file {releases:?}"))?;
218            let inputs: Vec<ReleaseInput> = serde_json::from_str(&raw)
219                .with_context(|| format!("Invalid JSON in releases file {releases:?}"))?;
220            let records: Vec<ReleaseRecord> =
221                inputs.into_iter().map(ReleaseInput::into_record).collect();
222
223            let policy_path = witness_policy.or_else(|| {
224                std::env::var("AUTHS_WITNESS_POLICY_PATH")
225                    .ok()
226                    .map(PathBuf::from)
227            });
228            let policy_result = load_witness_policy(policy_path.as_deref());
229
230            let sdk_ctx = build_auths_context(
231                &repo_path,
232                &ctx.env_config,
233                Some(passphrase_provider.clone()),
234            )?;
235            let framework = ComplianceFramework::from(framework);
236
237            let pack = if offline {
238                build_offline_evidence_pack(
239                    &sdk_ctx,
240                    org_did.clone(),
241                    &org_prefix,
242                    period,
243                    framework,
244                    &records,
245                    &policy_result,
246                    now,
247                )
248            } else {
249                build_evidence_pack(
250                    &sdk_ctx,
251                    org_did.clone(),
252                    &org_prefix,
253                    period,
254                    framework,
255                    &records,
256                    &policy_result,
257                    now,
258                )
259            }
260            .context("Failed to build compliance evidence pack")?;
261
262            let output = if predicate {
263                let vsa = VsaParams {
264                    verifier_id: verifier_id.clone(),
265                    time_verified: now,
266                    allow_list: Default::default(),
267                };
268                let report = build_framework_report(&pack, &vsa)
269                    .context("Failed to render the framework predicate")?;
270                if sign {
271                    let (org_alias, curve) = resolve_org_signing(&sdk_ctx, &org, key)?;
272                    sign_framework_report(&sdk_ctx, org_did.as_str(), &org_alias, curve, &report)
273                        .context("Failed to org-sign the framework predicate")?
274                        .to_canonical_json()
275                        .context("Failed to serialize DSSE envelope")?
276                } else {
277                    report
278                        .to_intoto_statement()
279                        .context("Failed to serialize the framework predicate")?
280                }
281            } else if sign {
282                let (org_alias, curve) = resolve_org_signing(&sdk_ctx, &org, key)?;
283                sign_evidence_pack(&sdk_ctx, org_did.as_str(), &org_alias, curve, &pack)
284                    .context("Failed to org-sign the evidence pack")?
285                    .to_canonical_json()
286                    .context("Failed to serialize DSSE envelope")?
287            } else {
288                pack.canonicalize()
289                    .context("Failed to serialize evidence pack")?
290            };
291
292            match &out {
293                Some(path) => {
294                    fs::write(path, &output)
295                        .with_context(|| format!("Failed to write pack to {path:?}"))?;
296                    eprintln!("✅ Compliance evidence pack written to {path:?}");
297                    eprintln!("   Rows:               {}", pack.rows.len());
298                    eprintln!("   Offline-verifiable: {}", pack.org_bundle.is_some());
299                    eprintln!("   Org-signed (DSSE):  {sign}");
300                    eprintln!(
301                        "   Witness verdict:    {}",
302                        pack.equivocation_visibility.label
303                    );
304                }
305                None => println!("{output}"),
306            }
307            Ok(())
308        }
309    }
310}
311
312impl ExecutableCommand for ComplianceCommand {
313    #[allow(clippy::disallowed_methods)] // CLI is the presentation boundary
314    fn execute(&self, ctx: &CliConfig) -> Result<()> {
315        handle_compliance(self.clone(), ctx, Utc::now())
316    }
317}