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 self.render_envelope(
384 Envelope::success(result.data, command_system),
385 default_fields,
386 command_path,
387 start,
388 &user_args,
389 &args,
390 identity,
391 )
392 }
393
394 #[doc(hidden)]
395 pub async fn run_no_auth<F, Fut>(
396 &self,
397 meta: CommandMeta,
398 command_path: &str,
399 user_args: ValueMap,
400 args: ValueMap,
401 default_fields: &str,
402 command: F,
403 ) -> Result<MiddlewareOutput>
404 where
405 F: FnOnce() -> Fut + Send,
406 Fut: Future<Output = Result<CommandResult>> + Send,
407 {
408 self.run(
409 MiddlewareRequest {
410 meta,
411 command_path,
412 system: fallback_system(command_path),
413 user_args,
414 args,
415 default_fields,
416 no_auth: true,
417 },
418 async move |_credential| command().await,
419 )
420 .await
421 }
422
423 async fn write_audit(&self, command_path: &str, args: &ValueMap, identity: &str, result: &str) {
424 if let Some(auditor) = &self.auditor
425 && let Err(err) = auditor
426 .append(command_path, args, identity, result, &self.reason)
427 .await
428 {
429 tracing::warn!(command = command_path, error = %err, "audit log write failed");
430 }
431 }
432
433 #[allow(clippy::too_many_arguments)]
434 async fn emit_activity(
435 &self,
436 command_path: &str,
437 args: &ValueMap,
438 credential: Option<&Credential>,
439 result: &str,
440 backend: &str,
441 error: &str,
442 start: Instant,
443 ) {
444 let Some(activity) = &self.activity else {
445 return;
446 };
447 let (identity, sub, account_type) = credential.map_or_else(
448 || (String::new(), String::new(), String::new()),
449 |credential| {
450 (
451 credential.identity.clone(),
452 credential.sub.clone(),
453 credential.account_type.clone(),
454 )
455 },
456 );
457 let duration_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
458 let event = ActivityEvent {
459 timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
460 app: self.app_id.clone(),
461 command: command_path.to_owned(),
462 env: self.env.clone(),
463 backend: backend.to_owned(),
464 identity,
465 sub,
466 account_type,
467 status: result.to_owned(),
468 error: error.to_owned(),
469 reason: self.reason.clone(),
470 args: args.clone(),
471 duration_ms,
472 meta: ValueMap::new(),
473 };
474 if let Err(err) = activity.emit(event).await {
475 tracing::warn!(command = command_path, error = %err, "activity emit failed");
476 }
477 }
478
479 fn render_schema_if_requested(
480 &self,
481 command_path: &str,
482 start: Instant,
483 user_args: &ValueMap,
484 effective_args: &ValueMap,
485 identity: &str,
486 ) -> Result<Option<MiddlewareOutput>> {
487 if self.schema
488 && let Some(schema) = self.schema_registry.get_by_path(command_path)
489 {
490 return self
491 .render_envelope(
492 Envelope::success(schema, self.app_id.clone()),
493 "",
494 command_path,
495 start,
496 user_args,
497 effective_args,
498 identity,
499 )
500 .map(Some);
501 }
502 Ok(None)
503 }
504
505 #[allow(clippy::too_many_arguments)]
506 fn render_envelope(
507 &self,
508 mut envelope: Envelope,
509 default_fields: &str,
510 command_path: &str,
511 start: Instant,
512 user_args: &ValueMap,
513 effective_args: &ValueMap,
514 identity: &str,
515 ) -> Result<MiddlewareOutput> {
516 if !is_valid_output_format(&self.output_format) {
517 let err = CliCoreError::InvalidOutputFormat(self.output_format.clone());
518 return self.render_error(
519 &err,
520 &self.app_id,
521 start,
522 user_args,
523 effective_args,
524 identity,
525 );
526 }
527 let output_format = self.output_format.parse::<OutputFormat>()?;
528 let mut fields = if self.fields.is_empty() {
529 default_fields
530 } else {
531 &self.fields
532 };
533 if output_format == OutputFormat::Human && self.fields.is_empty() {
534 fields = "";
535 }
536 if let Some(data) = &mut envelope.data {
537 let pagination = apply_pipeline(
538 data,
539 &PipelineOpts {
540 filter: self.filter.clone(),
541 limit: self.limit,
542 offset: self.offset,
543 expr: self.expr.clone(),
544 fields: fields.to_owned(),
545 },
546 )?;
547 if let Some(pagination) = pagination
548 && let Some(metadata) = &mut envelope.metadata
549 {
550 metadata.pagination = Some(pagination);
551 }
552 }
553 envelope.with_context(
554 command_path,
555 &self.env,
556 identity,
557 start.elapsed(),
558 Some(Value::Object(user_args.clone())),
559 Some(Value::Object(effective_args.clone())),
560 );
561 let system = envelope
562 .metadata
563 .as_ref()
564 .map(|metadata| metadata.system.as_str())
565 .unwrap_or_default()
566 .to_owned();
567 let prepared = envelope.prepare_for_render(&self.verbose);
568 let rendered = if output_format == OutputFormat::Human {
569 render_human_with_registry_for_schema(&prepared, &self.human_views, &system)
570 } else {
571 crate::output::render(output_format, &prepared)?
572 };
573 Ok(MiddlewareOutput {
574 envelope: prepared,
575 rendered,
576 exit_code: 0,
577 })
578 }
579
580 fn render_error(
581 &self,
582 err: &(dyn std::error::Error + 'static),
583 system: &str,
584 start: Instant,
585 user_args: &ValueMap,
586 effective_args: &ValueMap,
587 identity: &str,
588 ) -> Result<MiddlewareOutput> {
589 let mut envelope = build_error_envelope(err, system);
590 envelope.with_context(
591 "",
592 &self.env,
593 identity,
594 start.elapsed(),
595 Some(Value::Object(user_args.clone())),
596 Some(Value::Object(effective_args.clone())),
597 );
598 let prepared = envelope.prepare_for_render(&self.verbose);
599 let rendered = crate::output::render_format(&self.output_format, &prepared)?;
600 Ok(MiddlewareOutput {
601 envelope: prepared,
602 rendered,
603 exit_code: exit_code_for_error(err),
604 })
605 }
606}
607
608#[must_use]
610pub fn value_map(entries: impl IntoIterator<Item = (impl Into<String>, Value)>) -> ValueMap {
611 entries
612 .into_iter()
613 .map(|(key, value)| (key.into(), value))
614 .collect()
615}
616
617fn effective_request_system(system: &str, command_path: &str) -> String {
618 if system.is_empty() {
619 return fallback_system(command_path).to_owned();
620 }
621 system.to_owned()
622}
623
624fn fallback_system(command_path: &str) -> &str {
625 command_path
626 .split_once(':')
627 .map_or(command_path, |(system, _)| system)
628}
629
630impl From<CliCoreError> for Value {
631 fn from(error: CliCoreError) -> Self {
632 Value::String(error.to_string())
633 }
634}