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};
11
12use crate::{
13 CommandResult, Credential, Dispatcher, Result, SchemaRegistry, Tier,
14 error::{CliCoreError, exit_code_for_error},
15 output::{
16 Envelope, HumanViewRegistry, OutputFormat, PipelineOpts, apply_pipeline,
17 build_error_envelope, is_valid_output_format, render_human_with_registry_for_schema,
18 },
19};
20
21pub type ValueMap = Map<String, Value>;
23
24#[derive(Clone, Debug, Default, Eq, PartialEq)]
29pub struct CommandMeta {
30 pub dry_run_prompt: bool,
32 pub auth_metadata: BTreeMap<String, String>,
34 pub scopes: Vec<String>,
36}
37
38impl CommandMeta {
39 #[must_use]
41 pub fn provider(&self) -> Option<&str> {
42 self.auth_metadata.get("provider").map(String::as_str)
43 }
44
45 #[must_use]
47 pub fn tier(&self) -> Tier {
48 self.auth_metadata
49 .get("tier")
50 .and_then(|value| value.parse::<Tier>().ok())
51 .unwrap_or(Tier::Read)
52 }
53
54 #[must_use]
56 pub fn fixed_env(&self) -> Option<&str> {
57 self.auth_metadata.get("fixed_env").map(String::as_str)
58 }
59}
60
61#[async_trait]
62pub trait Authorizer: Send + Sync + std::fmt::Debug {
64 async fn authorize(
66 &self,
67 command_path: &str,
68 args: &ValueMap,
69 credential: Option<&Credential>,
70 reason: &str,
71 tier: Tier,
72 ) -> Result<()>;
73}
74
75#[async_trait]
76pub trait Auditor: Send + Sync + std::fmt::Debug {
78 async fn append(
80 &self,
81 command_path: &str,
82 args: &ValueMap,
83 identity: &str,
84 result: &str,
85 reason: &str,
86 ) -> Result<()>;
87}
88
89#[async_trait]
90pub trait ActivityEmitter: Send + Sync + std::fmt::Debug {
92 async fn emit(&self, event: ActivityEvent) -> Result<()>;
94}
95
96#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
98pub struct ActivityEvent {
99 pub timestamp: String,
101 pub app: String,
103 pub command: String,
105 pub env: String,
107 pub backend: String,
109 pub identity: String,
111 pub sub: String,
113 pub account_type: String,
115 pub status: String,
117 pub error: String,
119 pub reason: String,
121 pub args: ValueMap,
123 pub duration_ms: i64,
125 pub meta: ValueMap,
127}
128
129#[derive(Clone, Debug, Default)]
135pub struct Middleware {
136 pub authz: Option<Arc<dyn Authorizer>>,
138 pub auth: Dispatcher,
140 pub auditor: Option<Arc<dyn Auditor>>,
142 pub activity: Option<Arc<dyn ActivityEmitter>>,
144 pub app_id: String,
146 pub default_auth_provider: String,
148 pub output_format: String,
150 pub env: String,
152 pub verbose: String,
154 pub dry_run: bool,
156 pub fields: String,
158 pub filter: String,
160 pub expr: String,
162 pub limit: i64,
164 pub offset: i64,
166 pub reason: String,
168 pub schema: bool,
170 pub timeout: Option<Duration>,
172 pub debug: String,
174 pub search: String,
176 pub schema_registry: SchemaRegistry,
178 pub human_views: HumanViewRegistry,
180}
181
182#[derive(Clone, Debug, PartialEq)]
184pub struct MiddlewareOutput {
185 pub envelope: Envelope,
187 pub rendered: String,
189 pub exit_code: i32,
191}
192
193#[derive(Clone, Debug, PartialEq)]
195pub struct MiddlewareRequest<'request> {
196 pub meta: CommandMeta,
198 pub command_path: &'request str,
200 pub system: &'request str,
202 pub user_args: ValueMap,
204 pub args: ValueMap,
206 pub default_fields: &'request str,
208 pub no_auth: bool,
210}
211
212impl Middleware {
213 #[must_use]
215 pub fn new() -> Self {
216 Self::default()
217 }
218
219 pub async fn run<F, Fut, Output>(
221 &self,
222 request: MiddlewareRequest<'_>,
223 command: F,
224 ) -> Result<MiddlewareOutput>
225 where
226 F: FnOnce(Option<Credential>) -> Fut + Send,
227 Fut: Future<Output = Result<Output>> + Send,
228 Output: Into<CommandResult>,
229 {
230 let start = Instant::now();
231 let MiddlewareRequest {
232 meta,
233 command_path,
234 system,
235 user_args,
236 mut args,
237 default_fields,
238 no_auth,
239 } = request;
240 let command_system = effective_request_system(system, command_path);
241 if !no_auth && !self.env.is_empty() && !args.contains_key("env") {
242 args.insert("env".to_owned(), Value::String(self.env.clone()));
243 }
244
245 let credential = if no_auth {
246 None
247 } else {
248 let provider_name = meta
249 .provider()
250 .filter(|provider| !provider.is_empty())
251 .unwrap_or(&self.default_auth_provider);
252 let resolved_env = meta.fixed_env().unwrap_or(&self.env);
253 let tier_text = meta.auth_metadata.get("tier").map_or("", String::as_str);
254 match self
255 .auth
256 .get_credential(provider_name, resolved_env, command_path, tier_text)
257 .await
258 {
259 Ok(credential) => Some(credential),
260 Err(err) => {
261 self.write_audit(command_path, &args, "", "auth-error")
262 .await;
263 self.emit_activity(
264 command_path,
265 &args,
266 None,
267 "auth-error",
268 provider_name,
269 &err.to_string(),
270 start,
271 )
272 .await;
273 return self.render_error(&err, command_path, start, &user_args, &args, "");
274 }
275 }
276 };
277 let identity = credential
278 .as_ref()
279 .map_or("", |credential| credential.identity.as_str());
280
281 if no_auth
282 && let Some(output) =
283 self.render_schema_if_requested(command_path, start, &user_args, &args, identity)?
284 {
285 return Ok(output);
286 }
287
288 if let Some(authz) = &self.authz
289 && let Err(err) = authz
290 .authorize(
291 command_path,
292 &args,
293 credential.as_ref(),
294 &self.reason,
295 meta.tier(),
296 )
297 .await
298 {
299 self.write_audit(command_path, &args, identity, "denied")
300 .await;
301 self.emit_activity(
302 command_path,
303 &args,
304 credential.as_ref(),
305 "denied",
306 command_path,
307 &err.to_string(),
308 start,
309 )
310 .await;
311 return self.render_error(&err, command_path, start, &user_args, &args, identity);
312 }
313
314 if let Some(output) =
315 self.render_schema_if_requested(command_path, start, &user_args, &args, identity)?
316 {
317 return Ok(output);
318 }
319
320 if self.dry_run && meta.dry_run_prompt {
321 self.write_audit(command_path, &args, identity, "dry-run")
322 .await;
323 self.emit_activity(
324 command_path,
325 &args,
326 credential.as_ref(),
327 "dry-run",
328 command_path,
329 "",
330 start,
331 )
332 .await;
333 let envelope = Envelope::success(
334 json!({
335 "command": command_path,
336 "action": "dry-run: would execute",
337 }),
338 command_path,
339 )
340 .with_dry_run();
341 return self.render_envelope(
342 envelope,
343 "",
344 command_path,
345 start,
346 &user_args,
347 &args,
348 identity,
349 );
350 }
351
352 let result = match command(credential.clone()).await {
353 Ok(result) => result.into(),
354 Err(err) => {
355 let error_system = err.system().unwrap_or(&command_system);
356 self.write_audit(command_path, &args, identity, "error")
357 .await;
358 self.emit_activity(
359 command_path,
360 &args,
361 credential.as_ref(),
362 "error",
363 error_system,
364 &err.to_string(),
365 start,
366 )
367 .await;
368 return self.render_error(&err, error_system, start, &user_args, &args, identity);
369 }
370 };
371 self.write_audit(command_path, &args, identity, "ok").await;
372 self.emit_activity(
373 command_path,
374 &args,
375 credential.as_ref(),
376 "ok",
377 &command_system,
378 "",
379 start,
380 )
381 .await;
382
383 let CommandResult { data, metadata } = result;
384 self.render_envelope(
385 Envelope::success(data, command_system).with_next_actions(metadata.next_actions),
386 default_fields,
387 command_path,
388 start,
389 &user_args,
390 &args,
391 identity,
392 )
393 }
394
395 #[doc(hidden)]
396 pub async fn run_no_auth<F, Fut>(
397 &self,
398 meta: CommandMeta,
399 command_path: &str,
400 user_args: ValueMap,
401 args: ValueMap,
402 default_fields: &str,
403 command: F,
404 ) -> Result<MiddlewareOutput>
405 where
406 F: FnOnce() -> Fut + Send,
407 Fut: Future<Output = Result<CommandResult>> + Send,
408 {
409 self.run(
410 MiddlewareRequest {
411 meta,
412 command_path,
413 system: fallback_system(command_path),
414 user_args,
415 args,
416 default_fields,
417 no_auth: true,
418 },
419 async move |_credential| command().await,
420 )
421 .await
422 }
423
424 async fn write_audit(&self, command_path: &str, args: &ValueMap, identity: &str, result: &str) {
425 if let Some(auditor) = &self.auditor
426 && let Err(err) = auditor
427 .append(command_path, args, identity, result, &self.reason)
428 .await
429 {
430 tracing::warn!(command = command_path, error = %err, "audit log write failed");
431 }
432 }
433
434 #[allow(clippy::too_many_arguments)]
435 async fn emit_activity(
436 &self,
437 command_path: &str,
438 args: &ValueMap,
439 credential: Option<&Credential>,
440 result: &str,
441 backend: &str,
442 error: &str,
443 start: Instant,
444 ) {
445 let Some(activity) = &self.activity else {
446 return;
447 };
448 let (identity, sub, account_type) = credential.map_or_else(
449 || (String::new(), String::new(), String::new()),
450 |credential| {
451 (
452 credential.identity.clone(),
453 credential.sub.clone(),
454 credential.account_type.clone(),
455 )
456 },
457 );
458 let duration_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
459 let event = ActivityEvent {
460 timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
461 app: self.app_id.clone(),
462 command: command_path.to_owned(),
463 env: self.env.clone(),
464 backend: backend.to_owned(),
465 identity,
466 sub,
467 account_type,
468 status: result.to_owned(),
469 error: error.to_owned(),
470 reason: self.reason.clone(),
471 args: args.clone(),
472 duration_ms,
473 meta: ValueMap::new(),
474 };
475 if let Err(err) = activity.emit(event).await {
476 tracing::warn!(command = command_path, error = %err, "activity emit failed");
477 }
478 }
479
480 fn render_schema_if_requested(
481 &self,
482 command_path: &str,
483 start: Instant,
484 user_args: &ValueMap,
485 effective_args: &ValueMap,
486 identity: &str,
487 ) -> Result<Option<MiddlewareOutput>> {
488 if self.schema
489 && let Some(schema) = self.schema_registry.get_by_path(command_path)
490 {
491 return self
492 .render_envelope(
493 Envelope::success(schema, self.app_id.clone()),
494 "",
495 command_path,
496 start,
497 user_args,
498 effective_args,
499 identity,
500 )
501 .map(Some);
502 }
503 Ok(None)
504 }
505
506 #[allow(clippy::too_many_arguments)]
507 fn render_envelope(
508 &self,
509 mut envelope: Envelope,
510 default_fields: &str,
511 command_path: &str,
512 start: Instant,
513 user_args: &ValueMap,
514 effective_args: &ValueMap,
515 identity: &str,
516 ) -> Result<MiddlewareOutput> {
517 if !is_valid_output_format(&self.output_format) {
518 let err = CliCoreError::InvalidOutputFormat(self.output_format.clone());
519 return self.render_error(
520 &err,
521 &self.app_id,
522 start,
523 user_args,
524 effective_args,
525 identity,
526 );
527 }
528 let output_format = self.output_format.parse::<OutputFormat>()?;
529 let mut fields = if self.fields.is_empty() {
530 default_fields
531 } else {
532 &self.fields
533 };
534 if output_format == OutputFormat::Human && self.fields.is_empty() {
535 fields = "";
536 }
537 if let Some(data) = &mut envelope.data {
538 let pagination = apply_pipeline(
539 data,
540 &PipelineOpts {
541 filter: self.filter.clone(),
542 limit: self.limit,
543 offset: self.offset,
544 expr: self.expr.clone(),
545 fields: fields.to_owned(),
546 },
547 )?;
548 if let Some(pagination) = pagination
549 && let Some(metadata) = &mut envelope.metadata
550 {
551 metadata.pagination = Some(pagination);
552 }
553 }
554 envelope.with_context(
555 command_path,
556 &self.env,
557 identity,
558 start.elapsed(),
559 Some(Value::Object(user_args.clone())),
560 Some(Value::Object(effective_args.clone())),
561 );
562 let system = envelope
563 .metadata
564 .as_ref()
565 .map(|metadata| metadata.system.as_str())
566 .unwrap_or_default()
567 .to_owned();
568 let prepared = envelope.prepare_for_render(&self.verbose);
569 let rendered = if output_format == OutputFormat::Human {
570 render_human_with_registry_for_schema(&prepared, &self.human_views, &system)
571 } else {
572 crate::output::render(output_format, &prepared)?
573 };
574 Ok(MiddlewareOutput {
575 envelope: prepared,
576 rendered,
577 exit_code: 0,
578 })
579 }
580
581 fn render_error(
582 &self,
583 err: &(dyn std::error::Error + 'static),
584 system: &str,
585 start: Instant,
586 user_args: &ValueMap,
587 effective_args: &ValueMap,
588 identity: &str,
589 ) -> Result<MiddlewareOutput> {
590 let mut envelope = build_error_envelope(err, system);
591 envelope.with_context(
592 "",
593 &self.env,
594 identity,
595 start.elapsed(),
596 Some(Value::Object(user_args.clone())),
597 Some(Value::Object(effective_args.clone())),
598 );
599 let prepared = envelope.prepare_for_render(&self.verbose);
600 let rendered = crate::output::render_format(&self.output_format, &prepared)?;
601 Ok(MiddlewareOutput {
602 envelope: prepared,
603 rendered,
604 exit_code: exit_code_for_error(err),
605 })
606 }
607}
608
609#[must_use]
611pub fn value_map(entries: impl IntoIterator<Item = (impl Into<String>, Value)>) -> ValueMap {
612 entries
613 .into_iter()
614 .map(|(key, value)| (key.into(), value))
615 .collect()
616}
617
618fn effective_request_system(system: &str, command_path: &str) -> String {
619 if system.is_empty() {
620 return fallback_system(command_path).to_owned();
621 }
622 system.to_owned()
623}
624
625fn fallback_system(command_path: &str) -> &str {
626 command_path
627 .split_once(':')
628 .map_or(command_path, |(system, _)| system)
629}
630
631impl From<CliCoreError> for Value {
632 fn from(error: CliCoreError) -> Self {
633 Value::String(error.to_string())
634 }
635}