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