SEC-02: Implement credential rotation security improvements - Add JWT secret rotation to rotate-secret command - Generate 64-character HS512-compatible secrets - Automatic .env backup with timestamp - Atomic file updates via temp+rename pattern - Add health verification for rotated credentials - Route rotate-secret, rotate-secrets, vault commands in CLI - Add verification attempts for database and JWT endpoints Security improvements: - JWT_SECRET now rotatable (previously impossible) - Automatic rollback via backup files - Health checks catch configuration errors - Clear warnings about token invalidation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
420 lines
14 KiB
Rust
420 lines
14 KiB
Rust
use crate::core::shared::state::AppState;
|
|
use crate::designer::bas_analyzer::{BasFileAnalyzer, BasFileType, WorkflowMetadata};
|
|
use axum::{
|
|
extract::State,
|
|
http::StatusCode,
|
|
response::Html,
|
|
Json,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorkflowNode {
|
|
pub id: String,
|
|
pub node_type: NodeType,
|
|
pub position: Position,
|
|
pub config: NodeConfig,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Position {
|
|
pub x: f32,
|
|
pub y: f32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type")]
|
|
pub enum NodeType {
|
|
BotAgent { bot_name: String, action: String },
|
|
HumanApproval { approver: String, timeout: u32 },
|
|
Condition { expression: String },
|
|
Parallel { branches: Vec<String> },
|
|
Event { event_name: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct NodeConfig {
|
|
pub label: String,
|
|
pub description: Option<String>,
|
|
pub parameters: HashMap<String, String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorkflowConnection {
|
|
pub from_node: String,
|
|
pub to_node: String,
|
|
pub condition: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorkflowCanvas {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub nodes: Vec<WorkflowNode>,
|
|
pub connections: Vec<WorkflowConnection>,
|
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
impl WorkflowCanvas {
|
|
pub fn new(name: String) -> Self {
|
|
Self {
|
|
id: Uuid::new_v4(),
|
|
name,
|
|
nodes: Vec::new(),
|
|
connections: Vec::new(),
|
|
created_at: chrono::Utc::now(),
|
|
updated_at: chrono::Utc::now(),
|
|
}
|
|
}
|
|
|
|
pub fn generate_basic_code(&self) -> String {
|
|
let mut code = format!("' Generated workflow: {}\n", self.name);
|
|
code.push_str(&format!("ORCHESTRATE WORKFLOW \"{}\"\n", self.name));
|
|
|
|
let mut step_counter = 1;
|
|
for node in &self.nodes {
|
|
match &node.node_type {
|
|
NodeType::BotAgent { bot_name, action } => {
|
|
code.push_str(&format!(" STEP {}: BOT \"{}\" \"{}\"\n", step_counter, bot_name, action));
|
|
step_counter += 1;
|
|
}
|
|
NodeType::HumanApproval { approver, timeout } => {
|
|
code.push_str(&format!(" STEP {}: HUMAN APPROVAL FROM \"{}\"\n", step_counter, approver));
|
|
code.push_str(&format!(" TIMEOUT {}\n", timeout));
|
|
step_counter += 1;
|
|
}
|
|
NodeType::Condition { expression } => {
|
|
code.push_str(&format!(" IF {} THEN\n", expression));
|
|
}
|
|
NodeType::Parallel { branches: _ } => {
|
|
code.push_str(&format!(" STEP {}: PARALLEL\n", step_counter));
|
|
code.push_str(" BRANCH A: BOT \"branch-a\" \"process\"\n");
|
|
code.push_str(" BRANCH B: BOT \"branch-b\" \"process\"\n");
|
|
code.push_str(" END PARALLEL\n");
|
|
step_counter += 1;
|
|
}
|
|
NodeType::Event { event_name } => {
|
|
code.push_str(&format!(" PUBLISH EVENT \"{}\"\n", event_name));
|
|
}
|
|
}
|
|
}
|
|
|
|
code.push_str("END WORKFLOW\n");
|
|
code
|
|
}
|
|
|
|
pub fn detect_file_type(content: &str) -> BasFileType {
|
|
match BasFileAnalyzer::analyze_content(content) {
|
|
Ok(file_type) => file_type,
|
|
Err(_) => BasFileType::Regular,
|
|
}
|
|
}
|
|
|
|
pub fn get_metadata(&self) -> WorkflowMetadata {
|
|
let code = self.generate_basic_code();
|
|
BasFileAnalyzer::get_workflow_metadata(&code)
|
|
}
|
|
}
|
|
|
|
pub async fn workflow_designer_page(
|
|
State(_state): State<Arc<AppState>>,
|
|
) -> Result<Html<String>, StatusCode> {
|
|
let html = r##"
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Workflow Designer</title>
|
|
<script src="/static/htmx.min.js"></script>
|
|
<style>
|
|
.canvas {
|
|
width: 100%;
|
|
height: 600px;
|
|
border: 1px solid #ccc;
|
|
position: relative;
|
|
background: #f9f9f9;
|
|
}
|
|
.node {
|
|
position: absolute;
|
|
padding: 10px;
|
|
border: 2px solid #333;
|
|
background: white;
|
|
border-radius: 5px;
|
|
cursor: move;
|
|
min-width: 120px;
|
|
text-align: center;
|
|
}
|
|
.node.bot-agent { border-color: #007bff; background: #e7f3ff; }
|
|
.node.human-approval { border-color: #ffc107; background: #fff8e1; }
|
|
.node.condition { border-color: #28a745; background: #e8f5e9; }
|
|
.node.parallel { border-color: #6f42c1; background: #f3e5f5; }
|
|
.node.event { border-color: #fd7e14; background: #fff3e0; }
|
|
.toolbar {
|
|
padding: 10px;
|
|
background: #f8f9fa;
|
|
border-bottom: 1px solid #dee2e6;
|
|
}
|
|
.btn {
|
|
padding: 8px 16px;
|
|
margin: 0 5px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
.btn-primary { background: #007bff; color: white; }
|
|
.btn-success { background: #28a745; color: white; }
|
|
.btn-warning { background: #ffc107; color: black; }
|
|
.code-preview {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
background: #f8f9fa;
|
|
border: 1px solid #dee2e6;
|
|
font-family: monospace;
|
|
white-space: pre-wrap;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="toolbar">
|
|
<h2>Workflow Designer</h2>
|
|
<button class="btn btn-primary" onclick="addNode('bot-agent')">Add Bot</button>
|
|
<button class="btn btn-warning" onclick="addNode('human-approval')">Add Approval</button>
|
|
<button class="btn btn-success" onclick="addNode('condition')">Add Condition</button>
|
|
<button class="btn btn-primary" onclick="addNode('parallel')">Add Parallel</button>
|
|
<button class="btn" onclick="addNode('event')">Add Event</button>
|
|
<button class="btn btn-success" hx-post="/api/workflow/generate" hx-target="#code-preview">Generate Code</button>
|
|
<input type="file" id="file-input" accept=".bas" onchange="analyzeFile()" style="margin-left: 20px;">
|
|
<label for="file-input" class="btn">Analyze .bas File</label>
|
|
</div>
|
|
|
|
<div id="file-analysis" style="display:none; padding: 10px; background: #e8f4f8; border: 1px solid #bee5eb; margin: 10px 0;">
|
|
<h4>File Analysis Result</h4>
|
|
<div id="analysis-content"></div>
|
|
</div>
|
|
|
|
<div id="canvas" class="canvas" ondrop="drop(event)" ondragover="allowDrop(event)">
|
|
</div>
|
|
|
|
<div id="code-preview" class="code-preview">
|
|
Generated BASIC code will appear here...
|
|
</div>
|
|
|
|
<script>
|
|
let nodeCounter = 0;
|
|
let nodes = [];
|
|
|
|
function addNode(type) {
|
|
nodeCounter++;
|
|
const node = {
|
|
id: 'node-' + nodeCounter,
|
|
type: type,
|
|
x: 50 + (nodeCounter * 20),
|
|
y: 50 + (nodeCounter * 20)
|
|
};
|
|
nodes.push(node);
|
|
renderNode(node);
|
|
}
|
|
|
|
function renderNode(node) {
|
|
const canvas = document.getElementById('canvas');
|
|
const nodeEl = document.createElement('div');
|
|
nodeEl.className = 'node ' + node.type;
|
|
nodeEl.id = node.id;
|
|
nodeEl.draggable = true;
|
|
nodeEl.style.left = node.x + 'px';
|
|
nodeEl.style.top = node.y + 'px';
|
|
|
|
let content = '';
|
|
switch(node.type) {
|
|
case 'bot-agent':
|
|
content = '<strong>Bot Agent</strong><br><input type="text" placeholder="Bot Name " style="width:100px;margin:2px;"><br><input type="text" placeholder="Action" style="width:100px;margin:2px;">';
|
|
break;
|
|
case 'human-approval':
|
|
content = '<strong>Human Approval</strong><br><input type="text" placeholder="Approver" style="width:100px;margin:2px;"><br><input type="number" placeholder="Timeout" style="width:100px;margin:2px;">';
|
|
break;
|
|
case 'condition':
|
|
content = '<strong>Condition</strong><br><input type="text" placeholder="Expression" style="width:100px;margin:2px;">';
|
|
break;
|
|
case 'parallel':
|
|
content = '<strong>Parallel</strong><br>Multiple branches';
|
|
break;
|
|
case 'event':
|
|
content = '<strong>Event</strong><br><input type="text" placeholder="Event Name " style="width:100px;margin:2px;">';
|
|
break;
|
|
}
|
|
|
|
nodeEl.innerHTML = content;
|
|
nodeEl.ondragstart = drag;
|
|
canvas.appendChild(nodeEl);
|
|
}
|
|
|
|
function allowDrop(ev) {
|
|
ev.preventDefault();
|
|
}
|
|
|
|
function drag(ev) {
|
|
ev.dataTransfer.setData("text", ev.target.id);
|
|
}
|
|
|
|
function drop(ev) {
|
|
ev.preventDefault();
|
|
const data = ev.dataTransfer.getData("text");
|
|
const nodeEl = document.getElementById(data);
|
|
const rect = ev.currentTarget.getBoundingClientRect();
|
|
const x = ev.clientX - rect.left;
|
|
const y = ev.clientY - rect.top;
|
|
|
|
nodeEl.style.left = x + 'px';
|
|
nodeEl.style.top = y + 'px';
|
|
|
|
// Update node position in data
|
|
const node = nodes.find(n => n.id === data);
|
|
if (node) {
|
|
node.x = x;
|
|
node.y = y;
|
|
}
|
|
}
|
|
|
|
function analyzeFile() {
|
|
const fileInput = document.getElementById('file-input');
|
|
const file = fileInput.files[0];
|
|
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
const content = e.target.result;
|
|
|
|
fetch('/api/workflow/analyze', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ content: content })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
displayAnalysis(data);
|
|
})
|
|
.catch(error => {
|
|
console.error('Analysis failed:', error);
|
|
});
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
}
|
|
|
|
function displayAnalysis(analysis) {
|
|
const analysisDiv = document.getElementById('file-analysis');
|
|
const contentDiv = document.getElementById('analysis-content');
|
|
|
|
let html = `<p><strong>File Type:</strong> ${analysis.file_type}</p>`;
|
|
|
|
if (analysis.metadata) {
|
|
html += `<p><strong>Workflow Name:</strong> ${analysis.metadata.name}</p>`;
|
|
html += `<p><strong>Steps:</strong> ${analysis.metadata.step_count}</p>`;
|
|
html += `<p><strong>Bots Used:</strong> ${analysis.metadata.bot_count}</p>`;
|
|
html += `<p><strong>Human Approval:</strong> ${analysis.metadata.has_human_approval ? 'Yes' : 'No'}</p>`;
|
|
html += `<p><strong>Parallel Processing:</strong> ${analysis.metadata.has_parallel ? 'Yes' : 'No'}</p>`;
|
|
}
|
|
|
|
if (analysis.suggestions.length > 0) {
|
|
html += '<p><strong>Suggestions:</strong></p><ul>';
|
|
analysis.suggestions.forEach(suggestion => {
|
|
html += `<li>${suggestion}</li>`;
|
|
});
|
|
html += '</ul>';
|
|
}
|
|
|
|
contentDiv.innerHTML = html;
|
|
analysisDiv.style.display = 'block';
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"##;
|
|
|
|
Ok(Html(html.to_string()))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct GenerateCodeRequest {
|
|
pub nodes: Vec<WorkflowNode>,
|
|
pub connections: Vec<WorkflowConnection>,
|
|
}
|
|
|
|
pub async fn generate_workflow_code(
|
|
State(_state): State<Arc<AppState>>,
|
|
Json(request): Json<GenerateCodeRequest>,
|
|
) -> Result<Html<String>, StatusCode> {
|
|
let canvas = WorkflowCanvas {
|
|
id: Uuid::new_v4(),
|
|
name: "Generated Workflow".to_string(),
|
|
nodes: request.nodes,
|
|
connections: request.connections,
|
|
created_at: chrono::Utc::now(),
|
|
updated_at: chrono::Utc::now(),
|
|
};
|
|
|
|
let code = canvas.generate_basic_code();
|
|
Ok(Html(format!("<pre>{}</pre>", code)))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AnalyzeFileRequest {
|
|
pub content: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct AnalyzeFileResponse {
|
|
pub file_type: String,
|
|
pub metadata: Option<WorkflowMetadata>,
|
|
pub suggestions: Vec<String>,
|
|
}
|
|
|
|
pub async fn analyze_bas_file(
|
|
State(_state): State<Arc<AppState>>,
|
|
Json(request): Json<AnalyzeFileRequest>,
|
|
) -> Result<Json<AnalyzeFileResponse>, StatusCode> {
|
|
let file_type = WorkflowCanvas::detect_file_type(&request.content);
|
|
|
|
let (type_str, metadata, suggestions) = match file_type {
|
|
BasFileType::Workflow => {
|
|
let meta = BasFileAnalyzer::get_workflow_metadata(&request.content);
|
|
let mut suggestions = Vec::new();
|
|
|
|
if meta.step_count > 10 {
|
|
suggestions.push("Consider breaking this workflow into smaller sub-workflows".to_string());
|
|
}
|
|
if meta.bot_count > 5 {
|
|
suggestions.push("High bot count - ensure proper resource management".to_string());
|
|
}
|
|
if !meta.has_human_approval && meta.step_count > 3 {
|
|
suggestions.push("Consider adding human approval for complex workflows".to_string());
|
|
}
|
|
|
|
("workflow".to_string(), Some(meta), suggestions)
|
|
}
|
|
BasFileType::Tool => {
|
|
let suggestions = vec![
|
|
"Tools should be simple and focused on single operations".to_string(),
|
|
"Consider using USE TOOL instead of complex logic".to_string(),
|
|
];
|
|
("tool".to_string(), None, suggestions)
|
|
}
|
|
BasFileType::Regular => {
|
|
let suggestions = vec![
|
|
"Regular bot - consider upgrading to workflow for complex logic".to_string(),
|
|
];
|
|
("regular".to_string(), None, suggestions)
|
|
}
|
|
};
|
|
|
|
Ok(Json(AnalyzeFileResponse {
|
|
file_type: type_str,
|
|
metadata,
|
|
suggestions,
|
|
}))
|
|
}
|