Source commit: 309831e1e021f962c118452336776fd9a94025f9 Public tree identity: sha256:6fa95c1745579bd6256dbeb3d476db0b07c2f24aa9213b0f7783ce1adfc8aca5
375 lines
11 KiB
Rust
375 lines
11 KiB
Rust
use std::collections::BTreeMap;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
use crate::{ArtifactId, DownloadAction, DownloadError, ProcessId, ProjectId, TaskId, 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<String>,
|
|
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(TaskId),
|
|
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<String, PanelWidget>,
|
|
pub program_ui_events_enabled: bool,
|
|
pub control_plane_actions: Vec<ControlPlaneAction>,
|
|
}
|
|
|
|
#[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<String>,
|
|
label: impl Into<String>,
|
|
action: Result<DownloadAction, DownloadError>,
|
|
) -> 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<ControlPlaneAction>) {
|
|
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("<script>alert(1)</script>").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(TaskId::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(_)));
|
|
}
|
|
}
|