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::OnceCell;
12
13use crate::{
14 CommandResult, Credential, 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
22pub type ValueMap = Map<String, Value>;
24
25#[derive(Clone, Debug, Default, Eq, PartialEq)]
30pub struct CommandMeta {
31 pub dry_run_prompt: bool,
33 pub auth_metadata: BTreeMap<String, String>,
35 pub scopes: Vec<String>,
37}
38
39impl CommandMeta {
40 #[must_use]
42 pub fn provider(&self) -> Option<&str> {
43 self.auth_metadata.get("provider").map(String::as_str)
44 }
45
46 #[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 #[must_use]
57 pub fn fixed_env(&self) -> Option<&str> {
58 self.auth_metadata.get("fixed_env").map(String::as_str)
59 }
60}
61
62#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
76#[non_exhaustive]
77pub enum AuthRequirement {
78 #[default]
83 Required,
84 Optional,
92 None,
97}
98
99impl AuthRequirement {
100 #[must_use]
102 pub fn is_none(self) -> bool {
103 matches!(self, Self::None)
104 }
105
106 #[must_use]
108 pub fn is_required(self) -> bool {
109 matches!(self, Self::Required)
110 }
111
112 #[must_use]
114 pub fn is_optional(self) -> bool {
115 matches!(self, Self::Optional)
116 }
117}
118
119#[derive(Clone)]
133pub struct CredentialResolver {
134 inner: Arc<ResolverInner>,
135}
136
137#[derive(Debug)]
138struct ResolverInner {
139 auth: Dispatcher,
140 provider: String,
141 env: String,
142 command_path: String,
143 tier: String,
144 no_auth: bool,
145 cell: OnceCell<Credential>,
146}
147
148impl std::fmt::Debug for CredentialResolver {
149 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 formatter
151 .debug_struct("CredentialResolver")
152 .field("provider", &self.inner.provider)
153 .field("env", &self.inner.env)
154 .field("no_auth", &self.inner.no_auth)
155 .field("resolved", &self.inner.cell.get().is_some())
156 .finish_non_exhaustive()
157 }
158}
159
160impl CredentialResolver {
161 fn new(
162 auth: Dispatcher,
163 provider: String,
164 env: String,
165 command_path: String,
166 tier: String,
167 no_auth: bool,
168 ) -> Self {
169 Self {
170 inner: Arc::new(ResolverInner {
171 auth,
172 provider,
173 env,
174 command_path,
175 tier,
176 no_auth,
177 cell: OnceCell::new(),
178 }),
179 }
180 }
181
182 pub async fn resolve(&self) -> Result<Credential> {
190 if self.inner.no_auth {
191 return Err(CliCoreError::message(
192 "command is marked no_auth and has no credential",
193 ));
194 }
195 let inner = &self.inner;
196 let credential = inner
197 .cell
198 .get_or_try_init(async || {
199 inner
200 .auth
201 .get_credential(
202 &inner.provider,
203 &inner.env,
204 &inner.command_path,
205 &inner.tier,
206 )
207 .await
208 .map_err(|source| auth_resolution_error(&inner.provider, source))
213 })
214 .await?;
215 Ok(credential.clone())
216 }
217
218 pub async fn try_resolve(&self) -> Result<Option<Credential>> {
229 if self.inner.no_auth {
230 return Ok(None);
231 }
232 self.resolve().await.map(Some)
233 }
234
235 #[must_use]
240 pub fn peek(&self) -> Option<&Credential> {
241 self.inner.cell.get()
242 }
243}
244
245fn auth_resolution_error(provider: &str, source: CliCoreError) -> CliCoreError {
250 match source {
251 auth @ (CliCoreError::MissingAuthProvider(_) | CliCoreError::AuthProvider { .. }) => auth,
252 other => CliCoreError::AuthProvider {
253 provider: provider.to_owned(),
254 source: Box::new(other),
255 },
256 }
257}
258
259#[async_trait]
260pub trait Authorizer: Send + Sync + std::fmt::Debug {
268 async fn authorize(
270 &self,
271 command_path: &str,
272 args: &ValueMap,
273 credential: &CredentialResolver,
274 reason: &str,
275 tier: Tier,
276 ) -> Result<()>;
277}
278
279#[async_trait]
280pub trait Auditor: Send + Sync + std::fmt::Debug {
282 async fn append(
284 &self,
285 command_path: &str,
286 args: &ValueMap,
287 identity: &str,
288 result: &str,
289 reason: &str,
290 ) -> Result<()>;
291}
292
293#[async_trait]
294pub trait ActivityEmitter: Send + Sync + std::fmt::Debug {
296 async fn emit(&self, event: ActivityEvent) -> Result<()>;
298}
299
300#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
302pub struct ActivityEvent {
303 pub timestamp: String,
305 pub app: String,
307 pub command: String,
309 pub env: String,
311 pub backend: String,
313 pub identity: String,
315 pub sub: String,
317 pub account_type: String,
319 pub status: String,
321 pub error: String,
323 pub reason: String,
325 pub args: ValueMap,
327 pub duration_ms: i64,
329 pub meta: ValueMap,
331}
332
333#[derive(Clone, Debug, Default)]
339pub struct Middleware {
340 pub authz: Option<Arc<dyn Authorizer>>,
342 pub auth: Dispatcher,
344 pub auditor: Option<Arc<dyn Auditor>>,
346 pub activity: Option<Arc<dyn ActivityEmitter>>,
348 pub app_id: String,
350 pub default_auth_provider: String,
352 pub output_format: String,
354 pub env: String,
356 pub verbose: String,
358 pub dry_run: bool,
360 pub fields: String,
362 pub filter: String,
364 pub expr: String,
366 pub limit: i64,
368 pub offset: i64,
370 pub reason: String,
372 pub schema: bool,
374 pub timeout: Option<Duration>,
376 pub debug: String,
378 pub search: String,
380 pub schema_registry: SchemaRegistry,
382 pub human_views: HumanViewRegistry,
384}
385
386#[derive(Clone, Debug, PartialEq)]
388pub struct MiddlewareOutput {
389 pub envelope: Envelope,
391 pub rendered: String,
393 pub exit_code: i32,
395}
396
397#[derive(Clone, Debug, PartialEq)]
399pub struct MiddlewareRequest<'request> {
400 pub meta: CommandMeta,
402 pub command_path: &'request str,
404 pub system: &'request str,
406 pub user_args: ValueMap,
408 pub args: ValueMap,
410 pub default_fields: &'request str,
412 pub auth: AuthRequirement,
414}
415
416impl Middleware {
417 #[must_use]
419 pub fn new() -> Self {
420 Self::default()
421 }
422
423 pub async fn run<F, Fut, Output>(
425 &self,
426 request: MiddlewareRequest<'_>,
427 command: F,
428 ) -> Result<MiddlewareOutput>
429 where
430 F: FnOnce(CredentialResolver) -> Fut + Send,
431 Fut: Future<Output = Result<Output>> + Send,
432 Output: Into<CommandResult>,
433 {
434 let start = Instant::now();
435 let MiddlewareRequest {
436 meta,
437 command_path,
438 system,
439 user_args,
440 mut args,
441 default_fields,
442 auth,
443 } = request;
444 let no_auth = auth.is_none();
445 let command_system = effective_request_system(system, command_path);
446 if !no_auth && !self.env.is_empty() && !args.contains_key("env") {
447 args.insert("env".to_owned(), Value::String(self.env.clone()));
448 }
449
450 let provider_name = meta
454 .provider()
455 .filter(|provider| !provider.is_empty())
456 .unwrap_or(&self.default_auth_provider)
457 .to_owned();
458 let resolved_env = meta.fixed_env().unwrap_or(&self.env).to_owned();
459 let tier_text = meta
460 .auth_metadata
461 .get("tier")
462 .map_or("", String::as_str)
463 .to_owned();
464 let resolver = CredentialResolver::new(
465 self.auth.clone(),
466 provider_name.clone(),
467 resolved_env,
468 command_path.to_owned(),
469 tier_text,
470 no_auth,
471 );
472
473 if no_auth
474 && let Some(output) =
475 self.render_schema_if_requested(command_path, start, &user_args, &args, "")?
476 {
477 return Ok(output);
478 }
479
480 if let Some(authz) = &self.authz
481 && let Err(err) = authz
482 .authorize(command_path, &args, &resolver, &self.reason, meta.tier())
483 .await
484 {
485 let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
488 let had_auth_error = err.is_auth();
491 let result_tag = if had_auth_error {
492 "auth-error"
493 } else {
494 "denied"
495 };
496 let backend = if had_auth_error {
499 provider_name.as_str()
500 } else {
501 command_path
502 };
503 self.write_audit(command_path, &args, identity, result_tag)
504 .await;
505 self.emit_activity(
506 command_path,
507 &args,
508 resolver.peek(),
509 result_tag,
510 backend,
511 &err.to_string(),
512 start,
513 )
514 .await;
515 return self.render_error(&err, command_path, start, &user_args, &args, identity);
516 }
517
518 let schema_identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
522 if let Some(output) = self.render_schema_if_requested(
523 command_path,
524 start,
525 &user_args,
526 &args,
527 schema_identity,
528 )? {
529 return Ok(output);
530 }
531
532 if self.dry_run && meta.dry_run_prompt {
533 let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
534 self.write_audit(command_path, &args, identity, "dry-run")
535 .await;
536 self.emit_activity(
537 command_path,
538 &args,
539 resolver.peek(),
540 "dry-run",
541 command_path,
542 "",
543 start,
544 )
545 .await;
546 let envelope = Envelope::success(
547 json!({
548 "command": command_path,
549 "action": "dry-run: would execute",
550 }),
551 command_path,
552 )
553 .with_dry_run();
554 return self.render_envelope(
555 envelope,
556 "",
557 command_path,
558 start,
559 &user_args,
560 &args,
561 identity,
562 );
563 }
564
565 if auth.is_required()
572 && let Err(err) = resolver.resolve().await
573 {
574 self.write_audit(command_path, &args, "", "auth-error")
579 .await;
580 self.emit_activity(
581 command_path,
582 &args,
583 resolver.peek(),
584 "auth-error",
585 provider_name.as_str(),
586 &err.to_string(),
587 start,
588 )
589 .await;
590 return self.render_error(&err, command_path, start, &user_args, &args, "");
591 }
592
593 let result = match command(resolver.clone()).await {
594 Ok(result) => result.into(),
595 Err(err) => {
596 let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
602 let (result_tag, error_system, activity_backend) = if err.is_auth() {
603 ("auth-error", command_path, provider_name.as_str())
607 } else {
608 let system = err.system().unwrap_or(&command_system);
609 ("error", system, system)
610 };
611 self.write_audit(command_path, &args, identity, result_tag)
612 .await;
613 self.emit_activity(
614 command_path,
615 &args,
616 resolver.peek(),
617 result_tag,
618 activity_backend,
619 &err.to_string(),
620 start,
621 )
622 .await;
623 return self.render_error(&err, error_system, start, &user_args, &args, identity);
624 }
625 };
626 let identity = resolver.peek().map_or("", |cred| cred.identity.as_str());
628 self.write_audit(command_path, &args, identity, "ok").await;
629 self.emit_activity(
630 command_path,
631 &args,
632 resolver.peek(),
633 "ok",
634 &command_system,
635 "",
636 start,
637 )
638 .await;
639
640 let CommandResult { data, metadata } = result;
641 self.render_envelope(
642 Envelope::success(data, command_system).with_next_actions(metadata.next_actions),
643 default_fields,
644 command_path,
645 start,
646 &user_args,
647 &args,
648 identity,
649 )
650 }
651
652 #[doc(hidden)]
653 pub async fn run_no_auth<F, Fut>(
654 &self,
655 meta: CommandMeta,
656 command_path: &str,
657 user_args: ValueMap,
658 args: ValueMap,
659 default_fields: &str,
660 command: F,
661 ) -> Result<MiddlewareOutput>
662 where
663 F: FnOnce() -> Fut + Send,
664 Fut: Future<Output = Result<CommandResult>> + Send,
665 {
666 self.run(
667 MiddlewareRequest {
668 meta,
669 command_path,
670 system: fallback_system(command_path),
671 user_args,
672 args,
673 default_fields,
674 auth: AuthRequirement::None,
675 },
676 async move |_resolver| command().await,
677 )
678 .await
679 }
680
681 async fn write_audit(&self, command_path: &str, args: &ValueMap, identity: &str, result: &str) {
682 if let Some(auditor) = &self.auditor
683 && let Err(err) = auditor
684 .append(command_path, args, identity, result, &self.reason)
685 .await
686 {
687 tracing::warn!(command = command_path, error = %err, "audit log write failed");
688 }
689 }
690
691 #[allow(clippy::too_many_arguments)]
692 async fn emit_activity(
693 &self,
694 command_path: &str,
695 args: &ValueMap,
696 credential: Option<&Credential>,
697 result: &str,
698 backend: &str,
699 error: &str,
700 start: Instant,
701 ) {
702 let Some(activity) = &self.activity else {
703 return;
704 };
705 let (identity, sub, account_type) = credential.map_or_else(
706 || (String::new(), String::new(), String::new()),
707 |credential| {
708 (
709 credential.identity.clone(),
710 credential.sub.clone(),
711 credential.account_type.clone(),
712 )
713 },
714 );
715 let duration_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
716 let event = ActivityEvent {
717 timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
718 app: self.app_id.clone(),
719 command: command_path.to_owned(),
720 env: self.env.clone(),
721 backend: backend.to_owned(),
722 identity,
723 sub,
724 account_type,
725 status: result.to_owned(),
726 error: error.to_owned(),
727 reason: self.reason.clone(),
728 args: args.clone(),
729 duration_ms,
730 meta: ValueMap::new(),
731 };
732 if let Err(err) = activity.emit(event).await {
733 tracing::warn!(command = command_path, error = %err, "activity emit failed");
734 }
735 }
736
737 fn render_schema_if_requested(
738 &self,
739 command_path: &str,
740 start: Instant,
741 user_args: &ValueMap,
742 effective_args: &ValueMap,
743 identity: &str,
744 ) -> Result<Option<MiddlewareOutput>> {
745 if self.schema
746 && let Some(schema) = self.schema_registry.get_by_path(command_path)
747 {
748 return self
749 .render_envelope(
750 Envelope::success(schema, self.app_id.clone()),
751 "",
752 command_path,
753 start,
754 user_args,
755 effective_args,
756 identity,
757 )
758 .map(Some);
759 }
760 Ok(None)
761 }
762
763 #[allow(clippy::too_many_arguments)]
764 fn render_envelope(
765 &self,
766 mut envelope: Envelope,
767 default_fields: &str,
768 command_path: &str,
769 start: Instant,
770 user_args: &ValueMap,
771 effective_args: &ValueMap,
772 identity: &str,
773 ) -> Result<MiddlewareOutput> {
774 if !is_valid_output_format(&self.output_format) {
775 let err = CliCoreError::InvalidOutputFormat(self.output_format.clone());
776 return self.render_error(
777 &err,
778 &self.app_id,
779 start,
780 user_args,
781 effective_args,
782 identity,
783 );
784 }
785 let output_format = self.output_format.parse::<OutputFormat>()?;
786 let mut fields = if self.fields.is_empty() {
787 default_fields
788 } else {
789 &self.fields
790 };
791 if output_format == OutputFormat::Human && self.fields.is_empty() {
792 fields = "";
793 }
794 if let Some(data) = &mut envelope.data {
795 let pagination = apply_pipeline(
796 data,
797 &PipelineOpts {
798 filter: self.filter.clone(),
799 limit: self.limit,
800 offset: self.offset,
801 expr: self.expr.clone(),
802 fields: fields.to_owned(),
803 },
804 )?;
805 if let Some(pagination) = pagination
806 && let Some(metadata) = &mut envelope.metadata
807 {
808 metadata.pagination = Some(pagination);
809 }
810 }
811 envelope.with_context(
812 command_path,
813 &self.env,
814 identity,
815 start.elapsed(),
816 Some(Value::Object(user_args.clone())),
817 Some(Value::Object(effective_args.clone())),
818 );
819 let system = envelope
820 .metadata
821 .as_ref()
822 .map(|metadata| metadata.system.as_str())
823 .unwrap_or_default()
824 .to_owned();
825 let prepared = envelope.prepare_for_render(&self.verbose);
826 let rendered = if output_format == OutputFormat::Human {
827 render_human_with_registry_for_schema(&prepared, &self.human_views, &system)
828 } else {
829 crate::output::render(output_format, &prepared)?
830 };
831 Ok(MiddlewareOutput {
832 envelope: prepared,
833 rendered,
834 exit_code: 0,
835 })
836 }
837
838 fn render_error(
839 &self,
840 err: &(dyn std::error::Error + 'static),
841 system: &str,
842 start: Instant,
843 user_args: &ValueMap,
844 effective_args: &ValueMap,
845 identity: &str,
846 ) -> Result<MiddlewareOutput> {
847 let mut envelope = build_error_envelope(err, system);
848 envelope.with_context(
849 "",
850 &self.env,
851 identity,
852 start.elapsed(),
853 Some(Value::Object(user_args.clone())),
854 Some(Value::Object(effective_args.clone())),
855 );
856 let prepared = envelope.prepare_for_render(&self.verbose);
857 let rendered = crate::output::render_format(&self.output_format, &prepared)?;
858 Ok(MiddlewareOutput {
859 envelope: prepared,
860 rendered,
861 exit_code: exit_code_for_error(err),
862 })
863 }
864}
865
866#[must_use]
868pub fn value_map(entries: impl IntoIterator<Item = (impl Into<String>, Value)>) -> ValueMap {
869 entries
870 .into_iter()
871 .map(|(key, value)| (key.into(), value))
872 .collect()
873}
874
875fn effective_request_system(system: &str, command_path: &str) -> String {
876 if system.is_empty() {
877 return fallback_system(command_path).to_owned();
878 }
879 system.to_owned()
880}
881
882fn fallback_system(command_path: &str) -> &str {
883 command_path
884 .split_once(':')
885 .map_or(command_path, |(system, _)| system)
886}
887
888impl From<CliCoreError> for Value {
889 fn from(error: CliCoreError) -> Self {
890 Value::String(error.to_string())
891 }
892}