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}