Skip to main content

cli_engine/
middleware.rs

1use std::{
2    collections::BTreeMap,
3    future::Future,
4    sync::Arc,
5    time::{Duration, Instant},
6};
7
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value, json};
11
12use crate::{
13    CommandResult, Credential, Dispatcher, Result, SchemaRegistry, Tier,
14    error::{CliCoreError, exit_code_for_error},
15    output::{
16        Envelope, HumanViewRegistry, OutputFormat, PipelineOpts, apply_pipeline,
17        build_error_envelope, is_valid_output_format, render_human_with_registry_for_schema,
18    },
19};
20
21/// JSON object map used for command args and metadata.
22pub type ValueMap = Map<String, Value>;
23
24/// Per-command metadata consumed by middleware.
25///
26/// Command specs build this metadata automatically. Applications can also
27/// adjust it through `CliConfig::meta_resolver`.
28#[derive(Clone, Debug, Default, Eq, PartialEq)]
29pub struct CommandMeta {
30    /// Whether `--dry-run` should short-circuit command business logic.
31    pub dry_run_prompt: bool,
32    /// Provider-specific auth metadata.
33    pub auth_metadata: BTreeMap<String, String>,
34    /// OAuth-style scopes derived from `auth_metadata["scopes"]`.
35    pub scopes: Vec<String>,
36}
37
38impl CommandMeta {
39    /// Returns the selected auth provider, if one is present.
40    #[must_use]
41    pub fn provider(&self) -> Option<&str> {
42        self.auth_metadata.get("provider").map(String::as_str)
43    }
44
45    /// Returns the risk tier, defaulting to [`Tier::Read`].
46    #[must_use]
47    pub fn tier(&self) -> Tier {
48        self.auth_metadata
49            .get("tier")
50            .and_then(|value| value.parse::<Tier>().ok())
51            .unwrap_or(Tier::Read)
52    }
53
54    /// Returns a fixed auth environment override, if present.
55    #[must_use]
56    pub fn fixed_env(&self) -> Option<&str> {
57        self.auth_metadata.get("fixed_env").map(String::as_str)
58    }
59}
60
61#[async_trait]
62/// Authorization hook called after credential resolution and before business logic.
63pub trait Authorizer: Send + Sync + std::fmt::Debug {
64    /// Verifies whether `command_path` may run with the provided args, reason, and tier.
65    async fn authorize(
66        &self,
67        command_path: &str,
68        args: &ValueMap,
69        credential: Option<&Credential>,
70        reason: &str,
71        tier: Tier,
72    ) -> Result<()>;
73}
74
75#[async_trait]
76/// Audit hook called for success, error, denied, auth-error, and dry-run outcomes.
77pub trait Auditor: Send + Sync + std::fmt::Debug {
78    /// Appends an audit record.
79    async fn append(
80        &self,
81        command_path: &str,
82        args: &ValueMap,
83        identity: &str,
84        result: &str,
85        reason: &str,
86    ) -> Result<()>;
87}
88
89#[async_trait]
90/// Activity hook for structured command lifecycle events.
91pub trait ActivityEmitter: Send + Sync + std::fmt::Debug {
92    /// Emits one completed command event.
93    async fn emit(&self, event: ActivityEvent) -> Result<()>;
94}
95
96/// Structured activity event emitted after command execution paths.
97#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
98pub struct ActivityEvent {
99    /// UTC timestamp in RFC3339 seconds format.
100    pub timestamp: String,
101    /// CLI application id.
102    pub app: String,
103    /// Colon-separated command path.
104    pub command: String,
105    /// Selected environment.
106    pub env: String,
107    /// Backend/system id.
108    pub backend: String,
109    /// Human identity from the resolved credential.
110    pub identity: String,
111    /// Subject identifier from the resolved credential.
112    pub sub: String,
113    /// Account type from the resolved credential.
114    pub account_type: String,
115    /// Outcome such as `ok`, `error`, `denied`, `auth-error`, or `dry-run`.
116    pub status: String,
117    /// Error message for failed outcomes.
118    pub error: String,
119    /// User-provided reason.
120    pub reason: String,
121    /// Effective command args.
122    pub args: ValueMap,
123    /// Command duration in milliseconds.
124    pub duration_ms: i64,
125    /// Reserved extension metadata.
126    pub meta: ValueMap,
127}
128
129/// Cross-cutting command execution state and dependencies.
130///
131/// Middleware is intentionally a plain, cloneable struct so tests and command
132/// handlers can inspect what will be used for a run. Application setup usually
133/// mutates it through `CliConfig` hooks or `ModuleContext`.
134#[derive(Clone, Debug, Default)]
135pub struct Middleware {
136    /// Optional authorization provider.
137    pub authz: Option<Arc<dyn Authorizer>>,
138    /// Auth provider dispatcher.
139    pub auth: Dispatcher,
140    /// Optional audit sink.
141    pub auditor: Option<Arc<dyn Auditor>>,
142    /// Optional activity sink.
143    pub activity: Option<Arc<dyn ActivityEmitter>>,
144    /// Application id used in output metadata.
145    pub app_id: String,
146    /// Fallback auth provider for commands without an explicit provider.
147    pub default_auth_provider: String,
148    /// Output format: `json`, `human`, or `toon`.
149    pub output_format: String,
150    /// Selected environment.
151    pub env: String,
152    /// Metadata verbosity selector.
153    pub verbose: String,
154    /// Whether mutating commands should short-circuit.
155    pub dry_run: bool,
156    /// User field projection.
157    pub fields: String,
158    /// JMESPath per-item list predicate.
159    pub filter: String,
160    /// JMESPath whole-result expression.
161    pub expr: String,
162    /// Client-side page size.
163    pub limit: i64,
164    /// Client-side page offset.
165    pub offset: i64,
166    /// User reason passed to authorization and audit.
167    pub reason: String,
168    /// Whether schema rendering was requested.
169    pub schema: bool,
170    /// Optional command deadline.
171    pub timeout: Option<Duration>,
172    /// Debug selector, interpreted by applications.
173    pub debug: String,
174    /// Search query, interpreted before command execution.
175    pub search: String,
176    /// Output schema registry.
177    pub schema_registry: SchemaRegistry,
178    /// Human output view registry.
179    pub human_views: HumanViewRegistry,
180}
181
182/// Rendered result produced by middleware.
183#[derive(Clone, Debug, PartialEq)]
184pub struct MiddlewareOutput {
185    /// Prepared output envelope.
186    pub envelope: Envelope,
187    /// Rendered output string.
188    pub rendered: String,
189    /// Process-style exit code.
190    pub exit_code: i32,
191}
192
193/// Inputs for one middleware-managed command execution.
194#[derive(Clone, Debug, PartialEq)]
195pub struct MiddlewareRequest<'request> {
196    /// Per-command metadata used by authentication, authorization, dry-run, audit, and activity.
197    pub meta: CommandMeta,
198    /// Colon-separated command path.
199    pub command_path: &'request str,
200    /// Backend/system id used in output metadata and generic error attribution.
201    pub system: &'request str,
202    /// Arguments explicitly supplied by the user.
203    pub user_args: ValueMap,
204    /// Effective arguments, including defaults.
205    pub args: ValueMap,
206    /// Default field projection when `--fields` is absent.
207    pub default_fields: &'request str,
208    /// Whether credential resolution should be skipped.
209    pub no_auth: bool,
210}
211
212impl Middleware {
213    /// Creates middleware with empty registries and default dependencies.
214    #[must_use]
215    pub fn new() -> Self {
216        Self::default()
217    }
218
219    /// Runs the middleware chain for a command.
220    pub async fn run<F, Fut, Output>(
221        &self,
222        request: MiddlewareRequest<'_>,
223        command: F,
224    ) -> Result<MiddlewareOutput>
225    where
226        F: FnOnce(Option<Credential>) -> Fut + Send,
227        Fut: Future<Output = Result<Output>> + Send,
228        Output: Into<CommandResult>,
229    {
230        let start = Instant::now();
231        let MiddlewareRequest {
232            meta,
233            command_path,
234            system,
235            user_args,
236            mut args,
237            default_fields,
238            no_auth,
239        } = request;
240        let command_system = effective_request_system(system, command_path);
241        if !no_auth && !self.env.is_empty() && !args.contains_key("env") {
242            args.insert("env".to_owned(), Value::String(self.env.clone()));
243        }
244
245        let credential = if no_auth {
246            None
247        } else {
248            let provider_name = meta
249                .provider()
250                .filter(|provider| !provider.is_empty())
251                .unwrap_or(&self.default_auth_provider);
252            let resolved_env = meta.fixed_env().unwrap_or(&self.env);
253            let tier_text = meta.auth_metadata.get("tier").map_or("", String::as_str);
254            match self
255                .auth
256                .get_credential(provider_name, resolved_env, command_path, tier_text)
257                .await
258            {
259                Ok(credential) => Some(credential),
260                Err(err) => {
261                    self.write_audit(command_path, &args, "", "auth-error")
262                        .await;
263                    self.emit_activity(
264                        command_path,
265                        &args,
266                        None,
267                        "auth-error",
268                        provider_name,
269                        &err.to_string(),
270                        start,
271                    )
272                    .await;
273                    return self.render_error(&err, command_path, start, &user_args, &args, "");
274                }
275            }
276        };
277        let identity = credential
278            .as_ref()
279            .map_or("", |credential| credential.identity.as_str());
280
281        if no_auth
282            && let Some(output) =
283                self.render_schema_if_requested(command_path, start, &user_args, &args, identity)?
284        {
285            return Ok(output);
286        }
287
288        if let Some(authz) = &self.authz
289            && let Err(err) = authz
290                .authorize(
291                    command_path,
292                    &args,
293                    credential.as_ref(),
294                    &self.reason,
295                    meta.tier(),
296                )
297                .await
298        {
299            self.write_audit(command_path, &args, identity, "denied")
300                .await;
301            self.emit_activity(
302                command_path,
303                &args,
304                credential.as_ref(),
305                "denied",
306                command_path,
307                &err.to_string(),
308                start,
309            )
310            .await;
311            return self.render_error(&err, command_path, start, &user_args, &args, identity);
312        }
313
314        if let Some(output) =
315            self.render_schema_if_requested(command_path, start, &user_args, &args, identity)?
316        {
317            return Ok(output);
318        }
319
320        if self.dry_run && meta.dry_run_prompt {
321            self.write_audit(command_path, &args, identity, "dry-run")
322                .await;
323            self.emit_activity(
324                command_path,
325                &args,
326                credential.as_ref(),
327                "dry-run",
328                command_path,
329                "",
330                start,
331            )
332            .await;
333            let envelope = Envelope::success(
334                json!({
335                    "command": command_path,
336                    "action": "dry-run: would execute",
337                }),
338                command_path,
339            )
340            .with_dry_run();
341            return self.render_envelope(
342                envelope,
343                "",
344                command_path,
345                start,
346                &user_args,
347                &args,
348                identity,
349            );
350        }
351
352        let result = match command(credential.clone()).await {
353            Ok(result) => result.into(),
354            Err(err) => {
355                let error_system = err.system().unwrap_or(&command_system);
356                self.write_audit(command_path, &args, identity, "error")
357                    .await;
358                self.emit_activity(
359                    command_path,
360                    &args,
361                    credential.as_ref(),
362                    "error",
363                    error_system,
364                    &err.to_string(),
365                    start,
366                )
367                .await;
368                return self.render_error(&err, error_system, start, &user_args, &args, identity);
369            }
370        };
371        self.write_audit(command_path, &args, identity, "ok").await;
372        self.emit_activity(
373            command_path,
374            &args,
375            credential.as_ref(),
376            "ok",
377            &command_system,
378            "",
379            start,
380        )
381        .await;
382
383        self.render_envelope(
384            Envelope::success(result.data, command_system),
385            default_fields,
386            command_path,
387            start,
388            &user_args,
389            &args,
390            identity,
391        )
392    }
393
394    #[doc(hidden)]
395    pub async fn run_no_auth<F, Fut>(
396        &self,
397        meta: CommandMeta,
398        command_path: &str,
399        user_args: ValueMap,
400        args: ValueMap,
401        default_fields: &str,
402        command: F,
403    ) -> Result<MiddlewareOutput>
404    where
405        F: FnOnce() -> Fut + Send,
406        Fut: Future<Output = Result<CommandResult>> + Send,
407    {
408        self.run(
409            MiddlewareRequest {
410                meta,
411                command_path,
412                system: fallback_system(command_path),
413                user_args,
414                args,
415                default_fields,
416                no_auth: true,
417            },
418            async move |_credential| command().await,
419        )
420        .await
421    }
422
423    async fn write_audit(&self, command_path: &str, args: &ValueMap, identity: &str, result: &str) {
424        if let Some(auditor) = &self.auditor
425            && let Err(err) = auditor
426                .append(command_path, args, identity, result, &self.reason)
427                .await
428        {
429            tracing::warn!(command = command_path, error = %err, "audit log write failed");
430        }
431    }
432
433    #[allow(clippy::too_many_arguments)]
434    async fn emit_activity(
435        &self,
436        command_path: &str,
437        args: &ValueMap,
438        credential: Option<&Credential>,
439        result: &str,
440        backend: &str,
441        error: &str,
442        start: Instant,
443    ) {
444        let Some(activity) = &self.activity else {
445            return;
446        };
447        let (identity, sub, account_type) = credential.map_or_else(
448            || (String::new(), String::new(), String::new()),
449            |credential| {
450                (
451                    credential.identity.clone(),
452                    credential.sub.clone(),
453                    credential.account_type.clone(),
454                )
455            },
456        );
457        let duration_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
458        let event = ActivityEvent {
459            timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
460            app: self.app_id.clone(),
461            command: command_path.to_owned(),
462            env: self.env.clone(),
463            backend: backend.to_owned(),
464            identity,
465            sub,
466            account_type,
467            status: result.to_owned(),
468            error: error.to_owned(),
469            reason: self.reason.clone(),
470            args: args.clone(),
471            duration_ms,
472            meta: ValueMap::new(),
473        };
474        if let Err(err) = activity.emit(event).await {
475            tracing::warn!(command = command_path, error = %err, "activity emit failed");
476        }
477    }
478
479    fn render_schema_if_requested(
480        &self,
481        command_path: &str,
482        start: Instant,
483        user_args: &ValueMap,
484        effective_args: &ValueMap,
485        identity: &str,
486    ) -> Result<Option<MiddlewareOutput>> {
487        if self.schema
488            && let Some(schema) = self.schema_registry.get_by_path(command_path)
489        {
490            return self
491                .render_envelope(
492                    Envelope::success(schema, self.app_id.clone()),
493                    "",
494                    command_path,
495                    start,
496                    user_args,
497                    effective_args,
498                    identity,
499                )
500                .map(Some);
501        }
502        Ok(None)
503    }
504
505    #[allow(clippy::too_many_arguments)]
506    fn render_envelope(
507        &self,
508        mut envelope: Envelope,
509        default_fields: &str,
510        command_path: &str,
511        start: Instant,
512        user_args: &ValueMap,
513        effective_args: &ValueMap,
514        identity: &str,
515    ) -> Result<MiddlewareOutput> {
516        if !is_valid_output_format(&self.output_format) {
517            let err = CliCoreError::InvalidOutputFormat(self.output_format.clone());
518            return self.render_error(
519                &err,
520                &self.app_id,
521                start,
522                user_args,
523                effective_args,
524                identity,
525            );
526        }
527        let output_format = self.output_format.parse::<OutputFormat>()?;
528        let mut fields = if self.fields.is_empty() {
529            default_fields
530        } else {
531            &self.fields
532        };
533        if output_format == OutputFormat::Human && self.fields.is_empty() {
534            fields = "";
535        }
536        if let Some(data) = &mut envelope.data {
537            let pagination = apply_pipeline(
538                data,
539                &PipelineOpts {
540                    filter: self.filter.clone(),
541                    limit: self.limit,
542                    offset: self.offset,
543                    expr: self.expr.clone(),
544                    fields: fields.to_owned(),
545                },
546            )?;
547            if let Some(pagination) = pagination
548                && let Some(metadata) = &mut envelope.metadata
549            {
550                metadata.pagination = Some(pagination);
551            }
552        }
553        envelope.with_context(
554            command_path,
555            &self.env,
556            identity,
557            start.elapsed(),
558            Some(Value::Object(user_args.clone())),
559            Some(Value::Object(effective_args.clone())),
560        );
561        let system = envelope
562            .metadata
563            .as_ref()
564            .map(|metadata| metadata.system.as_str())
565            .unwrap_or_default()
566            .to_owned();
567        let prepared = envelope.prepare_for_render(&self.verbose);
568        let rendered = if output_format == OutputFormat::Human {
569            render_human_with_registry_for_schema(&prepared, &self.human_views, &system)
570        } else {
571            crate::output::render(output_format, &prepared)?
572        };
573        Ok(MiddlewareOutput {
574            envelope: prepared,
575            rendered,
576            exit_code: 0,
577        })
578    }
579
580    fn render_error(
581        &self,
582        err: &(dyn std::error::Error + 'static),
583        system: &str,
584        start: Instant,
585        user_args: &ValueMap,
586        effective_args: &ValueMap,
587        identity: &str,
588    ) -> Result<MiddlewareOutput> {
589        let mut envelope = build_error_envelope(err, system);
590        envelope.with_context(
591            "",
592            &self.env,
593            identity,
594            start.elapsed(),
595            Some(Value::Object(user_args.clone())),
596            Some(Value::Object(effective_args.clone())),
597        );
598        let prepared = envelope.prepare_for_render(&self.verbose);
599        let rendered = crate::output::render_format(&self.output_format, &prepared)?;
600        Ok(MiddlewareOutput {
601            envelope: prepared,
602            rendered,
603            exit_code: exit_code_for_error(err),
604        })
605    }
606}
607
608/// Convenience helper for building a JSON object map.
609#[must_use]
610pub fn value_map(entries: impl IntoIterator<Item = (impl Into<String>, Value)>) -> ValueMap {
611    entries
612        .into_iter()
613        .map(|(key, value)| (key.into(), value))
614        .collect()
615}
616
617fn effective_request_system(system: &str, command_path: &str) -> String {
618    if system.is_empty() {
619        return fallback_system(command_path).to_owned();
620    }
621    system.to_owned()
622}
623
624fn fallback_system(command_path: &str) -> &str {
625    command_path
626        .split_once(':')
627        .map_or(command_path, |(system, _)| system)
628}
629
630impl From<CliCoreError> for Value {
631    fn from(error: CliCoreError) -> Self {
632        Value::String(error.to_string())
633    }
634}