use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{ ArtifactId, DownloadAction, DownloadError, ProcessId, ProjectId, TaskInstanceId, TenantId, }; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PanelWidgetKind { Text { value: String, }, Progress { current: u64, total: u64, }, Button { action: String, }, Toggle { value: bool, }, Select { options: Vec, selected: String, }, ArtifactDownload { artifact: ArtifactId, }, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PanelWidget { pub id: String, pub label: String, pub kind: PanelWidgetKind, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum ControlPlaneAction { RestartTask(TaskInstanceId), CancelProcess, DebugProcess, DownloadArtifact(ArtifactId), } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PanelState { pub tenant: TenantId, pub project: ProjectId, pub process: ProcessId, pub widgets: BTreeMap, pub program_ui_events_enabled: bool, pub control_plane_actions: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PanelEventKind { ButtonClicked, ToggleChanged(bool), SelectChanged(String), } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PanelEvent { pub tenant: TenantId, pub project: ProjectId, pub process: ProcessId, pub widget_id: String, pub kind: PanelEventKind, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RateLimit { pub max_events: u64, pub used_events: u64, } #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum PanelError { #[error("custom HTML or JavaScript is not supported in operator panels")] CustomContentDenied, #[error( "operator panel widget `{0}` is not allowed to collect secrets or OAuth-like credentials" )] CredentialCollectionDenied(String), #[error("panel event scope does not match tenant/project/process")] ScopeMismatch, #[error("program UI events are disabled while debug process is stopped")] ProgramEventsDisabled, #[error("panel event rate limit exceeded")] RateLimited, #[error("unknown panel widget `{0}`")] UnknownWidget(String), #[error("artifact download action is unavailable: {0}")] DownloadUnavailable(String), } impl PanelState { pub fn new(tenant: TenantId, project: ProjectId, process: ProcessId) -> Self { Self { tenant, project, process, widgets: BTreeMap::new(), program_ui_events_enabled: true, control_plane_actions: Vec::new(), } } pub fn add_widget(&mut self, widget: PanelWidget) -> Result<(), PanelError> { validate_widget(&widget)?; self.widgets.insert(widget.id.clone(), widget); Ok(()) } pub fn add_download_widget_from_action( &mut self, widget_id: impl Into, label: impl Into, action: Result, ) -> Result<(), PanelError> { let action = action.map_err(|err| PanelError::DownloadUnavailable(err.to_string()))?; let artifact = action.artifact; self.add_widget(PanelWidget { id: widget_id.into(), label: label.into(), kind: PanelWidgetKind::ArtifactDownload { artifact: artifact.clone(), }, })?; self.control_plane_actions .push(ControlPlaneAction::DownloadArtifact(artifact)); Ok(()) } pub fn reject_custom_content(_html_or_js: &str) -> Result<(), PanelError> { Err(PanelError::CustomContentDenied) } pub fn freeze_program_ui_events(&mut self) { self.program_ui_events_enabled = false; } pub fn set_control_plane_actions(&mut self, actions: Vec) { self.control_plane_actions = actions; } pub fn accept_event( &self, event: &PanelEvent, limit: &mut RateLimit, ) -> Result<(), PanelError> { if !self.program_ui_events_enabled { return Err(PanelError::ProgramEventsDisabled); } if self.tenant != event.tenant || self.project != event.project || self.process != event.process { return Err(PanelError::ScopeMismatch); } if !self.widgets.contains_key(&event.widget_id) { return Err(PanelError::UnknownWidget(event.widget_id.clone())); } if limit.used_events >= limit.max_events { return Err(PanelError::RateLimited); } limit.used_events += 1; Ok(()) } pub fn control_plane_actions_available(&self) -> &[ControlPlaneAction] { &self.control_plane_actions } } fn validate_widget(widget: &PanelWidget) -> Result<(), PanelError> { let mut checked_text = vec![widget.id.as_str(), widget.label.as_str()]; match &widget.kind { PanelWidgetKind::Button { action } => checked_text.push(action), PanelWidgetKind::Select { options, selected } => { checked_text.push(selected); checked_text.extend(options.iter().map(String::as_str)); } PanelWidgetKind::Text { .. } | PanelWidgetKind::Progress { .. } | PanelWidgetKind::Toggle { .. } | PanelWidgetKind::ArtifactDownload { .. } => {} } let combined = checked_text.join(" ").to_ascii_lowercase(); if combined.contains("password") || combined.contains("token") || combined.contains("oauth") || combined.contains("secret") { return Err(PanelError::CredentialCollectionDenied(widget.id.clone())); } Ok(()) } #[cfg(test)] mod tests { use super::*; fn panel() -> PanelState { PanelState::new( TenantId::from("tenant"), ProjectId::from("project"), ProcessId::from("process"), ) } #[test] fn panel_uses_typed_widgets_and_rejects_custom_content() { let mut panel = panel(); panel .add_widget(PanelWidget { id: "progress".to_owned(), label: "Build".to_owned(), kind: PanelWidgetKind::Progress { current: 1, total: 2, }, }) .unwrap(); assert!(PanelState::reject_custom_content("").is_err()); assert!(panel.widgets.contains_key("progress")); } #[test] fn panel_rejects_password_or_oauth_collection_widgets() { let mut panel = panel(); let error = panel .add_widget(PanelWidget { id: "oauth_token".to_owned(), label: "OAuth Token".to_owned(), kind: PanelWidgetKind::Text { value: String::new(), }, }) .unwrap_err(); assert!(matches!(error, PanelError::CredentialCollectionDenied(_))); } #[test] fn panel_rejects_credential_collection_in_interactive_fields() { let mut panel = panel(); let button_error = panel .add_widget(PanelWidget { id: "continue".to_owned(), label: "Continue".to_owned(), kind: PanelWidgetKind::Button { action: "collect-secret".to_owned(), }, }) .unwrap_err(); assert!(matches!( button_error, PanelError::CredentialCollectionDenied(_) )); let select_error = panel .add_widget(PanelWidget { id: "auth-mode".to_owned(), label: "Auth Mode".to_owned(), kind: PanelWidgetKind::Select { options: vec!["password".to_owned(), "public key".to_owned()], selected: "public key".to_owned(), }, }) .unwrap_err(); assert!(matches!( select_error, PanelError::CredentialCollectionDenied(_) )); } #[test] fn panel_events_are_scoped_and_rate_limited() { let mut panel = panel(); panel .add_widget(PanelWidget { id: "restart".to_owned(), label: "Restart".to_owned(), kind: PanelWidgetKind::Button { action: "restart".to_owned(), }, }) .unwrap(); let event = PanelEvent { tenant: TenantId::from("tenant"), project: ProjectId::from("project"), process: ProcessId::from("process"), widget_id: "restart".to_owned(), kind: PanelEventKind::ButtonClicked, }; let mut limit = RateLimit { max_events: 1, used_events: 0, }; panel.accept_event(&event, &mut limit).unwrap(); assert_eq!( panel.accept_event(&event, &mut limit), Err(PanelError::RateLimited) ); } #[test] fn stopped_debug_process_keeps_control_plane_actions_available() { let mut panel = panel(); panel.freeze_program_ui_events(); panel.set_control_plane_actions(vec![ ControlPlaneAction::RestartTask(TaskInstanceId::from("task")), ControlPlaneAction::DownloadArtifact(ArtifactId::from("artifact")), ]); let event = PanelEvent { tenant: TenantId::from("tenant"), project: ProjectId::from("project"), process: ProcessId::from("process"), widget_id: "missing".to_owned(), kind: PanelEventKind::ButtonClicked, }; let mut limit = RateLimit { max_events: 1, used_events: 0, }; assert_eq!( panel.accept_event(&event, &mut limit), Err(PanelError::ProgramEventsDisabled) ); assert_eq!(panel.control_plane_actions_available().len(), 2); } #[test] fn download_widget_is_only_created_from_available_action() { let mut panel = panel(); let action = Ok(DownloadAction { artifact: ArtifactId::from("artifact"), source: crate::StorageLocation::RetainedNode(crate::NodeId::from("node")), scoped_token_subject: "tenant/project/process/artifact".to_owned(), }); panel .add_download_widget_from_action("download-artifact", "Download", action) .unwrap(); assert!(matches!( panel.widgets["download-artifact"].kind, PanelWidgetKind::ArtifactDownload { .. } )); assert!(matches!( panel.control_plane_actions_available()[0], ControlPlaneAction::DownloadArtifact(_) )); let before = panel.widgets.len(); let error = panel .add_download_widget_from_action( "missing-download", "Download", Err(DownloadError::Unavailable), ) .unwrap_err(); assert_eq!(panel.widgets.len(), before); assert!(matches!(error, PanelError::DownloadUnavailable(_))); } }