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        let CommandResult { data, metadata } = result;
384        self.render_envelope(
385            Envelope::success(data, command_system).with_next_actions(metadata.next_actions),
386            default_fields,
387            command_path,
388            start,
389            &user_args,
390            &args,
391            identity,
392        )
393    }
394
395    #[doc(hidden)]
396    pub async fn run_no_auth<F, Fut>(
397        &self,
398        meta: CommandMeta,
399        command_path: &str,
400        user_args: ValueMap,
401        args: ValueMap,
402        default_fields: &str,
403        command: F,
404    ) -> Result<MiddlewareOutput>
405    where
406        F: FnOnce() -> Fut + Send,
407        Fut: Future<Output = Result<CommandResult>> + Send,
408    {
409        self.run(
410            MiddlewareRequest {
411                meta,
412                command_path,
413                system: fallback_system(command_path),
414                user_args,
415                args,
416                default_fields,
417                no_auth: true,
418            },
419            async move |_credential| command().await,
420        )
421        .await
422    }
423
424    async fn write_audit(&self, command_path: &str, args: &ValueMap, identity: &str, result: &str) {
425        if let Some(auditor) = &self.auditor
426            && let Err(err) = auditor
427                .append(command_path, args, identity, result, &self.reason)
428                .await
429        {
430            tracing::warn!(command = command_path, error = %err, "audit log write failed");
431        }
432    }
433
434    #[allow(clippy::too_many_arguments)]
435    async fn emit_activity(
436        &self,
437        command_path: &str,
438        args: &ValueMap,
439        credential: Option<&Credential>,
440        result: &str,
441        backend: &str,
442        error: &str,
443        start: Instant,
444    ) {
445        let Some(activity) = &self.activity else {
446            return;
447        };
448        let (identity, sub, account_type) = credential.map_or_else(
449            || (String::new(), String::new(), String::new()),
450            |credential| {
451                (
452                    credential.identity.clone(),
453                    credential.sub.clone(),
454                    credential.account_type.clone(),
455                )
456            },
457        );
458        let duration_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
459        let event = ActivityEvent {
460            timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
461            app: self.app_id.clone(),
462            command: command_path.to_owned(),
463            env: self.env.clone(),
464            backend: backend.to_owned(),
465            identity,
466            sub,
467            account_type,
468            status: result.to_owned(),
469            error: error.to_owned(),
470            reason: self.reason.clone(),
471            args: args.clone(),
472            duration_ms,
473            meta: ValueMap::new(),
474        };
475        if let Err(err) = activity.emit(event).await {
476            tracing::warn!(command = command_path, error = %err, "activity emit failed");
477        }
478    }
479
480    fn render_schema_if_requested(
481        &self,
482        command_path: &str,
483        start: Instant,
484        user_args: &ValueMap,
485        effective_args: &ValueMap,
486        identity: &str,
487    ) -> Result<Option<MiddlewareOutput>> {
488        if self.schema
489            && let Some(schema) = self.schema_registry.get_by_path(command_path)
490        {
491            return self
492                .render_envelope(
493                    Envelope::success(schema, self.app_id.clone()),
494                    "",
495                    command_path,
496                    start,
497                    user_args,
498                    effective_args,
499                    identity,
500                )
501                .map(Some);
502        }
503        Ok(None)
504    }
505
506    #[allow(clippy::too_many_arguments)]
507    fn render_envelope(
508        &self,
509        mut envelope: Envelope,
510        default_fields: &str,
511        command_path: &str,
512        start: Instant,
513        user_args: &ValueMap,
514        effective_args: &ValueMap,
515        identity: &str,
516    ) -> Result<MiddlewareOutput> {
517        if !is_valid_output_format(&self.output_format) {
518            let err = CliCoreError::InvalidOutputFormat(self.output_format.clone());
519            return self.render_error(
520                &err,
521                &self.app_id,
522                start,
523                user_args,
524                effective_args,
525                identity,
526            );
527        }
528        let output_format = self.output_format.parse::<OutputFormat>()?;
529        let mut fields = if self.fields.is_empty() {
530            default_fields
531        } else {
532            &self.fields
533        };
534        if output_format == OutputFormat::Human && self.fields.is_empty() {
535            fields = "";
536        }
537        if let Some(data) = &mut envelope.data {
538            let pagination = apply_pipeline(
539                data,
540                &PipelineOpts {
541                    filter: self.filter.clone(),
542                    limit: self.limit,
543                    offset: self.offset,
544                    expr: self.expr.clone(),
545                    fields: fields.to_owned(),
546                },
547            )?;
548            if let Some(pagination) = pagination
549                && let Some(metadata) = &mut envelope.metadata
550            {
551                metadata.pagination = Some(pagination);
552            }
553        }
554        envelope.with_context(
555            command_path,
556            &self.env,
557            identity,
558            start.elapsed(),
559            Some(Value::Object(user_args.clone())),
560            Some(Value::Object(effective_args.clone())),
561        );
562        let system = envelope
563            .metadata
564            .as_ref()
565            .map(|metadata| metadata.system.as_str())
566            .unwrap_or_default()
567            .to_owned();
568        let prepared = envelope.prepare_for_render(&self.verbose);
569        let rendered = if output_format == OutputFormat::Human {
570            render_human_with_registry_for_schema(&prepared, &self.human_views, &system)
571        } else {
572            crate::output::render(output_format, &prepared)?
573        };
574        Ok(MiddlewareOutput {
575            envelope: prepared,
576            rendered,
577            exit_code: 0,
578        })
579    }
580
581    fn render_error(
582        &self,
583        err: &(dyn std::error::Error + 'static),
584        system: &str,
585        start: Instant,
586        user_args: &ValueMap,
587        effective_args: &ValueMap,
588        identity: &str,
589    ) -> Result<MiddlewareOutput> {
590        let mut envelope = build_error_envelope(err, system);
591        envelope.with_context(
592            "",
593            &self.env,
594            identity,
595            start.elapsed(),
596            Some(Value::Object(user_args.clone())),
597            Some(Value::Object(effective_args.clone())),
598        );
599        let prepared = envelope.prepare_for_render(&self.verbose);
600        let rendered = crate::output::render_format(&self.output_format, &prepared)?;
601        Ok(MiddlewareOutput {
602            envelope: prepared,
603            rendered,
604            exit_code: exit_code_for_error(err),
605        })
606    }
607}
608
609/// Convenience helper for building a JSON object map.
610#[must_use]
611pub fn value_map(entries: impl IntoIterator<Item = (impl Into<String>, Value)>) -> ValueMap {
612    entries
613        .into_iter()
614        .map(|(key, value)| (key.into(), value))
615        .collect()
616}
617
618fn effective_request_system(system: &str, command_path: &str) -> String {
619    if system.is_empty() {
620        return fallback_system(command_path).to_owned();
621    }
622    system.to_owned()
623}
624
625fn fallback_system(command_path: &str) -> &str {
626    command_path
627        .split_once(':')
628        .map_or(command_path, |(system, _)| system)
629}
630
631impl From<CliCoreError> for Value {
632    fn from(error: CliCoreError) -> Self {
633        Value::String(error.to_string())
634    }
635}