Skip to main content

cli_engine/
middleware.rs

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