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};
11use tokio::sync::{Mutex, OnceCell};
12
13use crate::{
14    CommandResult, Credential, CredentialRequest, Dispatcher, Result, SchemaRegistry, Tier,
15    error::{CliCoreError, exit_code_for_error},
16    output::{
17        Envelope, HumanViewRegistry, OutputFormat, PipelineOpts, apply_pipeline,
18        build_error_envelope, is_valid_output_format, render_human_with_registry_for_schema,
19    },
20};
21
22/// JSON object map used for command args and metadata.
23pub type ValueMap = Map<String, Value>;
24
25/// Per-command metadata consumed by middleware.
26///
27/// Command specs build this metadata automatically. Applications can also
28/// adjust it through `CliConfig::meta_resolver`.
29#[derive(Clone, Debug, Default, Eq, PartialEq)]
30pub struct CommandMeta {
31    /// Whether `--dry-run` should short-circuit command business logic.
32    pub dry_run_prompt: bool,
33    /// Provider-specific auth metadata.
34    pub auth_metadata: BTreeMap<String, String>,
35    /// OAuth-style scopes derived from `auth_metadata["scopes"]`.
36    pub scopes: Vec<String>,
37}
38
39impl CommandMeta {
40    /// Returns the selected auth provider, if one is present.
41    #[must_use]
42    pub fn provider(&self) -> Option<&str> {
43        self.auth_metadata.get("provider").map(String::as_str)
44    }
45
46    /// Returns the risk tier, defaulting to [`Tier::Read`].
47    #[must_use]
48    pub fn tier(&self) -> Tier {
49        self.auth_metadata
50            .get("tier")
51            .and_then(|value| value.parse::<Tier>().ok())
52            .unwrap_or(Tier::Read)
53    }
54
55    /// Returns a fixed auth environment override, if present.
56    #[must_use]
57    pub fn fixed_env(&self) -> Option<&str> {
58        self.auth_metadata.get("fixed_env").map(String::as_str)
59    }
60
61    /// Sets the OAuth scopes, keeping [`scopes`](CommandMeta::scopes) and
62    /// `auth_metadata["scopes"]` consistent.
63    ///
64    /// `scopes` is documented as derived from `auth_metadata["scopes"]`, so any
65    /// code that synthesizes or widens scopes (e.g. runtime step-up) should use
66    /// this rather than assigning the field directly, so metadata-aware providers
67    /// reading `auth_metadata` see the same set. An empty list removes the key.
68    pub fn set_scopes(&mut self, scopes: Vec<String>) {
69        if scopes.is_empty() {
70            self.auth_metadata.remove("scopes");
71        } else {
72            self.auth_metadata
73                .insert("scopes".to_owned(), scopes.join(" "));
74        }
75        self.scopes = scopes;
76    }
77}
78
79/// Declares whether a command requires an authenticated credential.
80///
81/// This is the policy that the engine enforces; it is separate from the
82/// *mechanism* of resolution (see [`CredentialResolver`]). The default is
83/// [`Required`](AuthRequirement::Required), which fails closed: the engine
84/// resolves the credential before the handler runs, so a command that should be
85/// gated behind authentication cannot execute unauthenticated even if its
86/// handler never reads the credential, and audit/activity identity is always
87/// populated for it.
88///
89/// `--schema` and `--dry-run` short-circuit before the engine resolves a
90/// `Required` credential, so they never trigger an authentication flow on their
91/// own regardless of requirement.
92#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
93#[non_exhaustive]
94pub enum AuthRequirement {
95    /// The engine resolves the credential before the handler runs (fail-closed).
96    ///
97    /// A failure to resolve is rendered as an `auth-error` and the handler never
98    /// runs. This is the default.
99    #[default]
100    Required,
101    /// Resolution is deferred to the handler.
102    ///
103    /// The engine does not resolve a credential on the command's behalf; the
104    /// handler (or an authorizer) triggers the auth flow only by calling
105    /// [`CredentialResolver::resolve`]/[`try_resolve`](CredentialResolver::try_resolve).
106    /// Use for commands that behave differently when authenticated but must still
107    /// run when the user is logged out.
108    Optional,
109    /// The command never authenticates and has no credential.
110    ///
111    /// Equivalent to the legacy `no_auth(true)` marker: default-env injection is
112    /// suppressed and [`CredentialResolver::resolve`] returns an error.
113    None,
114}
115
116impl AuthRequirement {
117    /// Returns `true` when the command never authenticates.
118    #[must_use]
119    pub fn is_none(self) -> bool {
120        matches!(self, Self::None)
121    }
122
123    /// Returns `true` when the engine must resolve the credential before the handler runs.
124    #[must_use]
125    pub fn is_required(self) -> bool {
126        matches!(self, Self::Required)
127    }
128
129    /// Returns `true` when resolution is deferred to the handler.
130    #[must_use]
131    pub fn is_optional(self) -> bool {
132        matches!(self, Self::Optional)
133    }
134}
135
136/// Resolves the credential for a single command invocation, memoizing the result.
137///
138/// Resolution — including any interactive browser/OAuth flow — runs once for a
139/// given scope set: a handler and an authorizer that both ask share a single
140/// resolution, and the engine resolves it up front for
141/// [`AuthRequirement::Required`] commands. For [`Optional`](AuthRequirement::Optional)
142/// commands resolution is deferred until a handler or authorizer calls
143/// [`resolve`](Self::resolve) or [`try_resolve`](Self::try_resolve), and
144/// `--schema`/`--dry-run` short-circuit before any resolution happens.
145///
146/// [`resolve_with_scopes`](Self::resolve_with_scopes) may trigger an *additional*
147/// resolution when it needs scopes the memoized credential does not yet cover
148/// (OAuth scope step-up); a scope-aware provider then re-authenticates for the
149/// wider set. Resolutions are serialized, so concurrent callers never launch
150/// overlapping interactive flows.
151///
152/// The resolved credential is memoized: callers that need no new scopes share a
153/// single resolution. Clones share the same underlying state, so the engine can
154/// observe (via [`peek`](Self::peek)) whatever a handler resolved.
155#[derive(Clone)]
156pub struct CredentialResolver {
157    inner: Arc<ResolverInner>,
158}
159
160#[derive(Debug)]
161struct ResolverInner {
162    auth: Dispatcher,
163    provider: String,
164    env: String,
165    command_path: String,
166    tier: String,
167    no_auth: bool,
168    /// Static command metadata; `meta.scopes` are always requested.
169    meta: CommandMeta,
170    /// Authoritative resolved credential plus the scopes it was requested with.
171    /// Serializes concurrent resolution and lets scope step-up replace a
172    /// previously-resolved (narrower) credential.
173    state: Mutex<ResolveState>,
174    /// Write-once mirror of the first resolved credential so [`CredentialResolver::peek`]
175    /// can lend a reference without holding a lock. `peek` (used for audit/activity
176    /// identity) therefore reflects the *first* resolved credential and is not
177    /// replaced by a later step-up. That is sound because step-up is required to
178    /// re-authenticate the *same* identity: [`resolve_scopes`](CredentialResolver::resolve_scopes)
179    /// aborts if a step-up returns a different account, so the mirrored identity
180    /// always matches the identity that performed every action in the command.
181    cell: OnceCell<Credential>,
182}
183
184#[derive(Debug, Default)]
185struct ResolveState {
186    credential: Option<Credential>,
187    requested: Vec<String>,
188}
189
190impl std::fmt::Debug for CredentialResolver {
191    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        formatter
193            .debug_struct("CredentialResolver")
194            .field("provider", &self.inner.provider)
195            .field("env", &self.inner.env)
196            .field("no_auth", &self.inner.no_auth)
197            .field("resolved", &self.inner.cell.get().is_some())
198            .finish_non_exhaustive()
199    }
200}
201
202impl CredentialResolver {
203    fn new(
204        auth: Dispatcher,
205        provider: String,
206        env: String,
207        command_path: String,
208        tier: String,
209        no_auth: bool,
210        meta: CommandMeta,
211    ) -> Self {
212        Self {
213            inner: Arc::new(ResolverInner {
214                auth,
215                provider,
216                env,
217                command_path,
218                tier,
219                no_auth,
220                meta,
221                state: Mutex::new(ResolveState::default()),
222                cell: OnceCell::new(),
223            }),
224        }
225    }
226
227    /// Resolves the credential, memoizing the result after the first success.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error when the command is marked [`no_auth`](crate::CommandSpec::no_auth)
232    /// (such commands have no credential), or when the auth provider fails to
233    /// produce one.
234    pub async fn resolve(&self) -> Result<Credential> {
235        if self.inner.no_auth {
236            return Err(CliCoreError::message(
237                "command is marked no_auth and has no credential",
238            ));
239        }
240        self.resolve_scopes(&[]).await
241    }
242
243    /// Resolves a credential that additionally covers `extra` scopes (on top of
244    /// the command's declared [`CommandMeta::scopes`]).
245    ///
246    /// Used by handlers whose required scopes are only known at runtime (for
247    /// example a generic `api call` that derives scopes from the target
248    /// endpoint). A scope-aware auth provider re-authenticates when the cached
249    /// token does not already cover the requested set.
250    ///
251    /// # Ordering with the transport injector
252    ///
253    /// The HTTP transport's bearer injector resolves its token through the
254    /// provider's scope-*unaware* path and caches the first token it sees for the
255    /// injector's lifetime. So when a handler both steps up scopes and makes HTTP
256    /// calls through that injector, call `resolve_with_scopes` (or
257    /// [`CommandContext::credential_with_scopes`](crate::CommandContext::credential_with_scopes))
258    /// **before** the first request: that populates the provider cache with the
259    /// wider-scoped token, which the injector then picks up. Resolving after the
260    /// injector's first `inject` would send the narrower token.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error when the command is marked
265    /// [`no_auth`](crate::CommandSpec::no_auth), or when the auth provider fails
266    /// to produce a credential.
267    pub async fn resolve_with_scopes(&self, extra: &[String]) -> Result<Credential> {
268        if self.inner.no_auth {
269            return Err(CliCoreError::message(
270                "command is marked no_auth and has no credential",
271            ));
272        }
273        self.resolve_scopes(extra).await
274    }
275
276    /// Shared resolution: returns the memoized credential when it already covers
277    /// the wanted scopes, otherwise (re)authenticates requesting the union and
278    /// updates the memoized credential.
279    async fn resolve_scopes(&self, extra: &[String]) -> Result<Credential> {
280        let inner = &self.inner;
281        let mut want = inner.meta.scopes.clone();
282        for scope in extra {
283            if !want.contains(scope) {
284                want.push(scope.clone());
285            }
286        }
287
288        let mut state = inner.state.lock().await;
289        if let Some(credential) = &state.credential
290            && want.iter().all(|scope| state.requested.contains(scope))
291        {
292            return Ok(credential.clone());
293        }
294
295        let mut requested = state.requested.clone();
296        for scope in &want {
297            if !requested.contains(scope) {
298                requested.push(scope.clone());
299            }
300        }
301        let mut meta = inner.meta.clone();
302        meta.set_scopes(requested.clone());
303        let req = CredentialRequest::new(&inner.env, &inner.command_path, &inner.tier, &meta);
304        let credential = inner
305            .auth
306            .get_credential_for(&inner.provider, &req)
307            .await
308            // Mark resolution failures so the engine can classify them as
309            // `auth-error` based on the error a handler actually returns.
310            .map_err(|source| auth_resolution_error(&inner.provider, source))?;
311        // Guard against a step-up that re-authenticates as a *different* identity.
312        // `peek` (audit/activity identity) reflects the first resolution, so a
313        // silent account switch would misattribute the elevated action. Abort
314        // rather than proceed under a mismatched identity.
315        if let Some(previous) = &state.credential {
316            let previous_key = identity_key(previous);
317            let new_key = identity_key(&credential);
318            if !previous_key.is_empty() && !new_key.is_empty() && previous_key != new_key {
319                return Err(CliCoreError::message(format!(
320                    "scope step-up authenticated as a different identity \
321                     (was {previous_key:?}, now {new_key:?}); aborting"
322                )));
323            }
324        }
325        state.credential = Some(credential.clone());
326        state.requested = requested;
327        // Mirror the first resolution for `peek`; ignored once already set.
328        drop(inner.cell.set(credential.clone()));
329        Ok(credential)
330    }
331
332    /// Resolves the credential when one is available.
333    ///
334    /// Returns `Ok(None)` for no-auth commands, `Ok(Some(_))` on success, and
335    /// propagates the provider error on failure. Use this for commands whose
336    /// auth is genuinely optional; most commands should call
337    /// [`resolve`](Self::resolve) instead.
338    ///
339    /// # Errors
340    ///
341    /// Propagates the auth provider error when resolution is attempted and fails.
342    pub async fn try_resolve(&self) -> Result<Option<Credential>> {
343        if self.inner.no_auth {
344            return Ok(None);
345        }
346        self.resolve().await.map(Some)
347    }
348
349    /// Returns the memoized credential without triggering resolution.
350    ///
351    /// Yields `None` until something resolves the credential. Used by the engine
352    /// to record identity in audit/activity output after a handler runs.
353    #[must_use]
354    pub fn peek(&self) -> Option<&Credential> {
355        self.inner.cell.get()
356    }
357}
358
359/// Marks a credential-resolution failure so its auth origin is detectable via
360/// [`CliCoreError::is_auth`], leaving errors that are already auth-typed
361/// unchanged. Display is preserved except for the `auth: provider …:` prefix that
362/// the [`AuthProvider`](CliCoreError::AuthProvider) wrapper adds.
363fn auth_resolution_error(provider: &str, source: CliCoreError) -> CliCoreError {
364    match source {
365        auth @ (CliCoreError::MissingAuthProvider(_) | CliCoreError::AuthProvider { .. }) => auth,
366        other => CliCoreError::AuthProvider {
367            provider: provider.to_owned(),
368            source: Box::new(other),
369        },
370    }
371}
372
373/// Stable identity discriminator for a credential: the subject (`sub`) when set,
374/// otherwise the human identity. Empty when the provider exposes neither, in
375/// which case the step-up identity guard cannot (and does not) compare.
376fn identity_key(credential: &Credential) -> &str {
377    if credential.sub.is_empty() {
378        credential.identity.as_str()
379    } else {
380        credential.sub.as_str()
381    }
382}
383
384#[async_trait]
385/// Authorization hook called before business logic.
386///
387/// The authorizer receives a [`CredentialResolver`] rather than an
388/// already-resolved credential so authorization remains lazy: an authorizer that
389/// does not need identity never triggers a credential/auth flow. Call
390/// [`CredentialResolver::try_resolve`] only when a decision actually depends on
391/// the credential.
392pub trait Authorizer: Send + Sync + std::fmt::Debug {
393    /// Verifies whether `command_path` may run with the provided args, reason, and tier.
394    async fn authorize(
395        &self,
396        command_path: &str,
397        args: &ValueMap,
398        credential: &CredentialResolver,
399        reason: &str,
400        tier: Tier,
401    ) -> Result<()>;
402}
403
404#[async_trait]
405/// Audit hook called for success, error, denied, auth-error, and dry-run outcomes.
406pub trait Auditor: Send + Sync + std::fmt::Debug {
407    /// Appends an audit record.
408    async fn append(
409        &self,
410        command_path: &str,
411        args: &ValueMap,
412        identity: &str,
413        result: &str,
414        reason: &str,
415    ) -> Result<()>;
416}
417
418#[async_trait]
419/// Activity hook for structured command lifecycle events.
420pub trait ActivityEmitter: Send + Sync + std::fmt::Debug {
421    /// Emits one completed command event.
422    async fn emit(&self, event: ActivityEvent) -> Result<()>;
423}
424
425/// Structured activity event emitted after command execution paths.
426#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
427pub struct ActivityEvent {
428    /// UTC timestamp in RFC3339 seconds format.
429    pub timestamp: String,
430    /// CLI application id.
431    pub app: String,
432    /// Colon-separated command path.
433    pub command: String,
434    /// Selected environment.
435    pub env: String,
436    /// Backend/system id.
437    pub backend: String,
438    /// Human identity from the resolved credential.
439    pub identity: String,
440    /// Subject identifier from the resolved credential.
441    pub sub: String,
442    /// Account type from the resolved credential.
443    pub account_type: String,
444    /// Outcome such as `ok`, `error`, `denied`, `auth-error`, or `dry-run`.
445    pub status: String,
446    /// Error message for failed outcomes.
447    pub error: String,
448    /// User-provided reason.
449    pub reason: String,
450    /// Effective command args.
451    pub args: ValueMap,
452    /// Command duration in milliseconds.
453    pub duration_ms: i64,
454    /// Reserved extension metadata.
455    pub meta: ValueMap,
456}
457
458/// Cross-cutting command execution state and dependencies.
459///
460/// Middleware is intentionally a plain, cloneable struct so tests and command
461/// handlers can inspect what will be used for a run. Application setup usually
462/// mutates it through `CliConfig` hooks or `ModuleContext`.
463#[derive(Clone, Debug, Default)]
464pub struct Middleware {
465    /// Optional authorization provider.
466    pub authz: Option<Arc<dyn Authorizer>>,
467    /// Auth provider dispatcher.
468    pub auth: Dispatcher,
469    /// Optional audit sink.
470    pub auditor: Option<Arc<dyn Auditor>>,
471    /// Optional activity sink.
472    pub activity: Option<Arc<dyn ActivityEmitter>>,
473    /// Application id used in output metadata.
474    pub app_id: String,
475    /// Fallback auth provider for commands without an explicit provider.
476    pub default_auth_provider: String,
477    /// Output format: `json`, `human`, or `toon`.
478    pub output_format: String,
479    /// Selected environment.
480    pub env: String,
481    /// Metadata verbosity selector.
482    pub verbose: String,
483    /// Whether mutating commands should short-circuit.
484    pub dry_run: bool,
485    /// User field projection.
486    pub fields: String,
487    /// JMESPath per-item list predicate.
488    pub filter: String,
489    /// JMESPath whole-result expression.
490    pub expr: String,
491    /// Client-side page size.
492    pub limit: i64,
493    /// Client-side page offset.
494    pub offset: i64,
495    /// User reason passed to authorization and audit.
496    pub reason: String,
497    /// Whether schema rendering was requested.
498    pub schema: bool,
499    /// Optional command deadline.
500    pub timeout: Option<Duration>,
501    /// Debug selector, interpreted by applications.
502    pub debug: String,
503    /// Search query, interpreted before command execution.
504    pub search: String,
505    /// Output schema registry.
506    pub schema_registry: SchemaRegistry,
507    /// Human output view registry.
508    pub human_views: HumanViewRegistry,
509}
510
511/// Rendered result produced by middleware.
512#[derive(Clone, Debug, PartialEq)]
513pub struct MiddlewareOutput {
514    /// Prepared output envelope.
515    pub envelope: Envelope,
516    /// Rendered output string.
517    pub rendered: String,
518    /// Process-style exit code.
519    pub exit_code: i32,
520}
521
522/// Inputs for one middleware-managed command execution.
523#[derive(Clone, Debug, PartialEq)]
524pub struct MiddlewareRequest<'request> {
525    /// Per-command metadata used by authentication, authorization, dry-run, audit, and activity.
526    pub meta: CommandMeta,
527    /// Colon-separated command path.
528    pub command_path: &'request str,
529    /// Backend/system id used in output metadata and generic error attribution.
530    pub system: &'request str,
531    /// Arguments explicitly supplied by the user.
532    pub user_args: ValueMap,
533    /// Effective arguments, including defaults.
534    pub args: ValueMap,
535    /// Default field projection when `--fields` is absent.
536    pub default_fields: &'request str,
537    /// Authentication requirement enforced by the engine for this command.
538    pub auth: AuthRequirement,
539}
540
541impl Middleware {
542    /// Creates middleware with empty registries and default dependencies.
543    #[must_use]
544    pub fn new() -> Self {
545        Self::default()
546    }
547
548    /// Runs the middleware chain for a command.
549    pub async fn run<F, Fut, Output>(
550        &self,
551        request: MiddlewareRequest<'_>,
552        command: F,
553    ) -> Result<MiddlewareOutput>
554    where
555        F: FnOnce(CredentialResolver) -> Fut + Send,
556        Fut: Future<Output = Result<Output>> + Send,
557        Output: Into<CommandResult>,
558    {
559        let start = Instant::now();
560        let MiddlewareRequest {
561            meta,
562            command_path,
563            system,
564            user_args,
565            mut args,
566            default_fields,
567            auth,
568        } = request;
569        let no_auth = auth.is_none();
570        let command_system = effective_request_system(system, command_path);
571        if !no_auth && !self.env.is_empty() && !args.contains_key("env") {
572            args.insert("env".to_owned(), Value::String(self.env.clone()));
573        }
574
575        // Build a lazy resolver instead of resolving eagerly. No auth flow runs
576        // until a handler or authorizer actually asks for the credential, so
577        // commands that never use it (and `--schema`/`--dry-run`) skip auth.
578        let provider_name = meta
579            .provider()
580            .filter(|provider| !provider.is_empty())
581            .unwrap_or(&self.default_auth_provider)
582            .to_owned();
583        let resolved_env = meta.fixed_env().unwrap_or(&self.env).to_owned();
584        let tier_text = meta
585            .auth_metadata
586            .get("tier")
587            .map_or("", String::as_str)
588            .to_owned();
589        let resolver = CredentialResolver::new(
590            self.auth.clone(),
591            provider_name.clone(),
592            resolved_env,
593            command_path.to_owned(),
594            tier_text,
595            no_auth,
596            meta.clone(),
597        );
598
599        if no_auth
600            && let Some(output) =
601                self.render_schema_if_requested(command_path, start, &user_args, &args, "")?
602        {
603            return Ok(output);
604        }
605
606        if let Some(authz) = &self.authz
607            && let Err(err) = authz
608                .authorize(command_path, &args, &resolver, &self.reason, meta.tier())
609                .await
610        {
611            // An authorizer may have resolved the credential to make its
612            // decision; reflect whatever it resolved in audit identity.
613            let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
614            // Classify by the error the authorizer returned: a propagated
615            // resolution failure is auth-typed; a policy denial is not.
616            let had_auth_error = err.is_auth();
617            let result_tag = if had_auth_error {
618                "auth-error"
619            } else {
620                "denied"
621            };
622            // Attribute auth-provider failures to the provider so telemetry can
623            // distinguish them from command backends.
624            let backend = if had_auth_error {
625                provider_name.as_str()
626            } else {
627                command_path
628            };
629            self.write_audit(command_path, &args, identity, result_tag)
630                .await;
631            self.emit_activity(
632                command_path,
633                &args,
634                resolver.peek(),
635                result_tag,
636                backend,
637                &err.to_string(),
638                start,
639            )
640            .await;
641            return self.render_error(&err, command_path, start, &user_args, &args, identity);
642        }
643
644        // If the authorizer resolved the credential, include its identity in the
645        // schema output metadata. `peek()` never triggers resolution, so schema
646        // still doesn't provoke auth on its own.
647        let schema_identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
648        if let Some(output) = self.render_schema_if_requested(
649            command_path,
650            start,
651            &user_args,
652            &args,
653            schema_identity,
654        )? {
655            return Ok(output);
656        }
657
658        if self.dry_run && meta.dry_run_prompt {
659            let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
660            self.write_audit(command_path, &args, identity, "dry-run")
661                .await;
662            self.emit_activity(
663                command_path,
664                &args,
665                resolver.peek(),
666                "dry-run",
667                command_path,
668                "",
669                start,
670            )
671            .await;
672            let envelope = Envelope::success(
673                json!({
674                    "command": command_path,
675                    "action": "dry-run: would execute",
676                }),
677                command_path,
678            )
679            .with_dry_run();
680            return self.render_envelope(
681                envelope,
682                "",
683                command_path,
684                start,
685                &user_args,
686                &args,
687                identity,
688            );
689        }
690
691        // Fail closed by default: for `Required` commands the engine resolves the
692        // credential before the handler runs, so a command that must be
693        // authenticated cannot execute unauthenticated even if its handler never
694        // reads the credential, and its audit/activity identity is always
695        // populated. `--schema`/`--dry-run` return above, so they never reach this
696        // point; `Optional`/`None` commands defer resolution to the handler.
697        if auth.is_required()
698            && let Err(err) = resolver.resolve().await
699        {
700            // Mirror the handler-path auth-error treatment: classify as
701            // `auth-error` and attribute the activity backend to the auth provider
702            // so telemetry can distinguish auth-provider failures from command
703            // backends. Resolution failed, so there is no identity to record.
704            self.write_audit(command_path, &args, "", "auth-error")
705                .await;
706            self.emit_activity(
707                command_path,
708                &args,
709                resolver.peek(),
710                "auth-error",
711                provider_name.as_str(),
712                &err.to_string(),
713                start,
714            )
715            .await;
716            return self.render_error(&err, command_path, start, &user_args, &args, "");
717        }
718
719        let result = match command(resolver.clone()).await {
720            Ok(result) => result.into(),
721            Err(err) => {
722                // A deferred `resolve()` failure surfaces as a handler error;
723                // classify it as `auth-error` when the error the handler returned
724                // is itself auth-typed. A handler that swallows a resolution
725                // failure and then fails for another reason returns a non-auth
726                // error here, so it is not misclassified.
727                let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
728                let (result_tag, error_system, activity_backend) = if err.is_auth() {
729                    // Render against the command path, but attribute the activity
730                    // backend to the auth provider so telemetry can distinguish
731                    // auth-provider failures from command backends.
732                    ("auth-error", command_path, provider_name.as_str())
733                } else {
734                    let system = err.system().unwrap_or(&command_system);
735                    ("error", system, system)
736                };
737                self.write_audit(command_path, &args, identity, result_tag)
738                    .await;
739                self.emit_activity(
740                    command_path,
741                    &args,
742                    resolver.peek(),
743                    result_tag,
744                    activity_backend,
745                    &err.to_string(),
746                    start,
747                )
748                .await;
749                return self.render_error(&err, error_system, start, &user_args, &args, identity);
750            }
751        };
752        // The handler may have resolved the credential; surface its identity.
753        let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
754        self.write_audit(command_path, &args, identity, "ok").await;
755        self.emit_activity(
756            command_path,
757            &args,
758            resolver.peek(),
759            "ok",
760            &command_system,
761            "",
762            start,
763        )
764        .await;
765
766        let CommandResult { data, metadata } = result;
767        self.render_envelope(
768            Envelope::success(data, command_system).with_next_actions(metadata.next_actions),
769            default_fields,
770            command_path,
771            start,
772            &user_args,
773            &args,
774            identity,
775        )
776    }
777
778    #[doc(hidden)]
779    pub async fn run_no_auth<F, Fut>(
780        &self,
781        meta: CommandMeta,
782        command_path: &str,
783        user_args: ValueMap,
784        args: ValueMap,
785        default_fields: &str,
786        command: F,
787    ) -> Result<MiddlewareOutput>
788    where
789        F: FnOnce() -> Fut + Send,
790        Fut: Future<Output = Result<CommandResult>> + Send,
791    {
792        self.run(
793            MiddlewareRequest {
794                meta,
795                command_path,
796                system: fallback_system(command_path),
797                user_args,
798                args,
799                default_fields,
800                auth: AuthRequirement::None,
801            },
802            async move |_resolver| command().await,
803        )
804        .await
805    }
806
807    async fn write_audit(&self, command_path: &str, args: &ValueMap, identity: &str, result: &str) {
808        if let Some(auditor) = &self.auditor
809            && let Err(err) = auditor
810                .append(command_path, args, identity, result, &self.reason)
811                .await
812        {
813            tracing::warn!(command = command_path, error = %err, "audit log write failed");
814        }
815    }
816
817    #[allow(clippy::too_many_arguments)]
818    async fn emit_activity(
819        &self,
820        command_path: &str,
821        args: &ValueMap,
822        credential: Option<&Credential>,
823        result: &str,
824        backend: &str,
825        error: &str,
826        start: Instant,
827    ) {
828        let Some(activity) = &self.activity else {
829            return;
830        };
831        let (identity, sub, account_type) = credential.map_or_else(
832            || (String::new(), String::new(), String::new()),
833            |credential| {
834                (
835                    credential.identity.clone(),
836                    credential.sub.clone(),
837                    credential.account_type.clone(),
838                )
839            },
840        );
841        let duration_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
842        let event = ActivityEvent {
843            timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
844            app: self.app_id.clone(),
845            command: command_path.to_owned(),
846            env: self.env.clone(),
847            backend: backend.to_owned(),
848            identity,
849            sub,
850            account_type,
851            status: result.to_owned(),
852            error: error.to_owned(),
853            reason: self.reason.clone(),
854            args: args.clone(),
855            duration_ms,
856            meta: ValueMap::new(),
857        };
858        if let Err(err) = activity.emit(event).await {
859            tracing::warn!(command = command_path, error = %err, "activity emit failed");
860        }
861    }
862
863    fn render_schema_if_requested(
864        &self,
865        command_path: &str,
866        start: Instant,
867        user_args: &ValueMap,
868        effective_args: &ValueMap,
869        identity: &str,
870    ) -> Result<Option<MiddlewareOutput>> {
871        if self.schema
872            && let Some(schema) = self.schema_registry.get_by_path(command_path)
873        {
874            return self
875                .render_envelope(
876                    Envelope::success(schema, self.app_id.clone()),
877                    "",
878                    command_path,
879                    start,
880                    user_args,
881                    effective_args,
882                    identity,
883                )
884                .map(Some);
885        }
886        Ok(None)
887    }
888
889    #[allow(clippy::too_many_arguments)]
890    fn render_envelope(
891        &self,
892        mut envelope: Envelope,
893        default_fields: &str,
894        command_path: &str,
895        start: Instant,
896        user_args: &ValueMap,
897        effective_args: &ValueMap,
898        identity: &str,
899    ) -> Result<MiddlewareOutput> {
900        if !is_valid_output_format(&self.output_format) {
901            let err = CliCoreError::InvalidOutputFormat(self.output_format.clone());
902            return self.render_error(
903                &err,
904                &self.app_id,
905                start,
906                user_args,
907                effective_args,
908                identity,
909            );
910        }
911        let output_format = self.output_format.parse::<OutputFormat>()?;
912        let mut fields = if self.fields.is_empty() {
913            default_fields
914        } else {
915            &self.fields
916        };
917        if output_format == OutputFormat::Human && self.fields.is_empty() {
918            fields = "";
919        }
920        if let Some(data) = &mut envelope.data {
921            let pagination = apply_pipeline(
922                data,
923                &PipelineOpts {
924                    filter: self.filter.clone(),
925                    limit: self.limit,
926                    offset: self.offset,
927                    expr: self.expr.clone(),
928                    fields: fields.to_owned(),
929                },
930            )?;
931            if let Some(pagination) = pagination
932                && let Some(metadata) = &mut envelope.metadata
933            {
934                metadata.pagination = Some(pagination);
935            }
936        }
937        envelope.with_context(
938            command_path,
939            &self.env,
940            identity,
941            start.elapsed(),
942            Some(Value::Object(user_args.clone())),
943            Some(Value::Object(effective_args.clone())),
944        );
945        let system = envelope
946            .metadata
947            .as_ref()
948            .map(|metadata| metadata.system.as_str())
949            .unwrap_or_default()
950            .to_owned();
951        let prepared = envelope.prepare_for_render(&self.verbose);
952        let rendered = if output_format == OutputFormat::Human {
953            render_human_with_registry_for_schema(&prepared, &self.human_views, &system)
954        } else {
955            crate::output::render(output_format, &prepared)?
956        };
957        Ok(MiddlewareOutput {
958            envelope: prepared,
959            rendered,
960            exit_code: 0,
961        })
962    }
963
964    fn render_error(
965        &self,
966        err: &(dyn std::error::Error + 'static),
967        system: &str,
968        start: Instant,
969        user_args: &ValueMap,
970        effective_args: &ValueMap,
971        identity: &str,
972    ) -> Result<MiddlewareOutput> {
973        let mut envelope = build_error_envelope(err, system);
974        envelope.with_context(
975            "",
976            &self.env,
977            identity,
978            start.elapsed(),
979            Some(Value::Object(user_args.clone())),
980            Some(Value::Object(effective_args.clone())),
981        );
982        let prepared = envelope.prepare_for_render(&self.verbose);
983        let rendered = crate::output::render_format(&self.output_format, &prepared)?;
984        Ok(MiddlewareOutput {
985            envelope: prepared,
986            rendered,
987            exit_code: exit_code_for_error(err),
988        })
989    }
990}
991
992/// Convenience helper for building a JSON object map.
993#[must_use]
994pub fn value_map(entries: impl IntoIterator<Item = (impl Into<String>, Value)>) -> ValueMap {
995    entries
996        .into_iter()
997        .map(|(key, value)| (key.into(), value))
998        .collect()
999}
1000
1001fn effective_request_system(system: &str, command_path: &str) -> String {
1002    if system.is_empty() {
1003        return fallback_system(command_path).to_owned();
1004    }
1005    system.to_owned()
1006}
1007
1008fn fallback_system(command_path: &str) -> &str {
1009    command_path
1010        .split_once(':')
1011        .map_or(command_path, |(system, _)| system)
1012}
1013
1014impl From<CliCoreError> for Value {
1015    fn from(error: CliCoreError) -> Self {
1016        Value::String(error.to_string())
1017    }
1018}