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