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