mirror of
https://github.com/its-a-feature/Mythic
synced 2026-06-08 14:55:38 +00:00
eventing prompt user input/approval
This commit is contained in:
@@ -1259,7 +1259,10 @@ const getTaskWidth = (node) => {
|
||||
return Math.max(325, (nodeText.length * 8) + 10)
|
||||
}
|
||||
const getEventNodeWidth = (node) => {
|
||||
return Math.max(190, (node.maxNameLength * 8) + 120);
|
||||
const labelLength = Math.max(node.maxNameLength || 0, String(node?.data?.label || "").length);
|
||||
const actionLength = String(node?.data?.action || "").length;
|
||||
const statusLength = String(node?.data?.status || "").replace(/_/g, " ").length;
|
||||
return Math.min(420, Math.max(260, (labelLength * 7) + 130, (actionLength * 7) + 105, (statusLength * 7) + 105));
|
||||
}
|
||||
const getBrowserscriptWidth = (node) => {
|
||||
let nodeText = " ";
|
||||
@@ -1282,7 +1285,7 @@ const getHeight = (node) => {
|
||||
return Math.max(node.height || 0, 132);
|
||||
}
|
||||
if(node.type === "eventNode"){
|
||||
return 88;
|
||||
return node?.data?.status ? 112 : 96;
|
||||
}
|
||||
return 80;
|
||||
}
|
||||
|
||||
@@ -583,6 +583,65 @@ const inputOptionsData = {
|
||||
description: "this is a completely custom input that's whatever you desire"
|
||||
}
|
||||
}
|
||||
const userInteractionInputTypes = ["string", "number", "boolean", "json"];
|
||||
const userInteractionBotApprovalApprovers = {
|
||||
operator: {
|
||||
label: "Any operator",
|
||||
description: "Operators and the operation lead can approve when this workflow runs as bot."
|
||||
},
|
||||
lead: {
|
||||
label: "Operation lead",
|
||||
description: "Only the operation lead can approve when this workflow runs as bot."
|
||||
},
|
||||
};
|
||||
const getDefaultUserInteractionConfig = () => ({
|
||||
approval_required: false,
|
||||
approval_prompt: "",
|
||||
approval_policy: {
|
||||
bot_context: {
|
||||
approver: "operator",
|
||||
},
|
||||
},
|
||||
input_required: false,
|
||||
input_prompt: "",
|
||||
inputs: [],
|
||||
});
|
||||
const normalizeUserInteractionConfig = (config) => {
|
||||
const normalizedConfig = {...getDefaultUserInteractionConfig(), ...(config || {})};
|
||||
const approvalPolicy = normalizedConfig.approval_policy && typeof normalizedConfig.approval_policy === "object" ? normalizedConfig.approval_policy : {};
|
||||
const botContext = approvalPolicy.bot_context && typeof approvalPolicy.bot_context === "object" ? approvalPolicy.bot_context : {};
|
||||
normalizedConfig.approval_policy = {
|
||||
bot_context: {
|
||||
approver: Object.keys(userInteractionBotApprovalApprovers).includes(botContext.approver) ? botContext.approver : "operator",
|
||||
},
|
||||
};
|
||||
if(!Array.isArray(normalizedConfig.inputs)){
|
||||
if(normalizedConfig.inputs && typeof normalizedConfig.inputs === "object"){
|
||||
normalizedConfig.inputs = Object.entries(normalizedConfig.inputs).map(([name, value]) => {
|
||||
if(value && typeof value === "object" && !Array.isArray(value)){
|
||||
return {name, ...value};
|
||||
}
|
||||
return {name, type: "string", required: false, description: "", default_value: value || ""};
|
||||
});
|
||||
}else{
|
||||
normalizedConfig.inputs = [];
|
||||
}
|
||||
}
|
||||
normalizedConfig.inputs = normalizedConfig.inputs.map((input) => ({
|
||||
name: input?.name || "",
|
||||
type: input?.type || "string",
|
||||
required: Boolean(input?.required),
|
||||
description: input?.description || "",
|
||||
default_value: input?.default_value === undefined || input?.default_value === null ? "" : (
|
||||
typeof input.default_value === "string" ? input.default_value : JSON.stringify(input.default_value)
|
||||
),
|
||||
}));
|
||||
return normalizedConfig;
|
||||
}
|
||||
const hasUserInteractionConfig = (config) => {
|
||||
const normalizedConfig = normalizeUserInteractionConfig(config);
|
||||
return Boolean(normalizedConfig.approval_required || normalizedConfig.input_required || normalizedConfig.inputs.length > 0);
|
||||
}
|
||||
const outputOptionsData = {
|
||||
payload_create: {
|
||||
output_fields: payloadFields,
|
||||
@@ -2468,6 +2527,178 @@ const actionOptionsData = {
|
||||
}
|
||||
|
||||
}
|
||||
const EventingStepUserInteraction = ({config, onChange}) => {
|
||||
const userInteraction = normalizeUserInteractionConfig(config);
|
||||
const updateUserInteraction = (updates) => {
|
||||
onChange({...userInteraction, ...updates});
|
||||
}
|
||||
const updateInputField = (index, fieldName, value) => {
|
||||
const nextInputs = userInteraction.inputs.map((input, i) => {
|
||||
if(i !== index){
|
||||
return {...input};
|
||||
}
|
||||
return {...input, [fieldName]: value};
|
||||
});
|
||||
updateUserInteraction({inputs: nextInputs});
|
||||
}
|
||||
const addInputField = () => {
|
||||
updateUserInteraction({
|
||||
input_required: true,
|
||||
inputs: [...userInteraction.inputs, {
|
||||
name: "",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "",
|
||||
default_value: "",
|
||||
}],
|
||||
});
|
||||
}
|
||||
const removeInputField = (index) => {
|
||||
const nextInputs = [...userInteraction.inputs];
|
||||
nextInputs.splice(index, 1);
|
||||
updateUserInteraction({inputs: nextInputs});
|
||||
}
|
||||
return (
|
||||
<div className="mythic-eventing-step-dynamic-section mythic-eventing-step-user-interaction-section">
|
||||
<div className="mythic-eventing-step-switch-stack">
|
||||
<label className="mythic-eventing-step-switch-row">
|
||||
<span className="mythic-eventing-step-switch-copy">
|
||||
<span className="mythic-eventing-step-switch-title">Require approval</span>
|
||||
<span className="mythic-eventing-step-switch-subtitle">Pause before this step runs until an operator approves it.</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={userInteraction.approval_required}
|
||||
onChange={(event) => updateUserInteraction({approval_required: event.target.checked})}
|
||||
color={"info"}
|
||||
inputProps={{ 'aria-label': 'require approval' }}
|
||||
/>
|
||||
</label>
|
||||
{userInteraction.approval_required &&
|
||||
<MythicTextField
|
||||
placeholder="Prompt shown to the approving user..."
|
||||
onChange={(name, value) => updateUserInteraction({approval_prompt: value})}
|
||||
value={userInteraction.approval_prompt}
|
||||
marginBottom="0px"
|
||||
/>
|
||||
}
|
||||
{userInteraction.approval_required &&
|
||||
<div className="mythic-eventing-step-approval-policy-row">
|
||||
<TextField
|
||||
label="Bot approval role"
|
||||
select
|
||||
size="small"
|
||||
value={userInteraction.approval_policy.bot_context.approver}
|
||||
onChange={(event) => updateUserInteraction({
|
||||
approval_policy: {
|
||||
...userInteraction.approval_policy,
|
||||
bot_context: {
|
||||
...userInteraction.approval_policy.bot_context,
|
||||
approver: event.target.value,
|
||||
},
|
||||
},
|
||||
})}
|
||||
fullWidth
|
||||
>
|
||||
{Object.entries(userInteractionBotApprovalApprovers).map(([value, data]) => (
|
||||
<MenuItem key={`user-interaction-approver-${value}`} value={value}>{data.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<div className="mythic-eventing-step-helper-text">
|
||||
{userInteractionBotApprovalApprovers[userInteraction.approval_policy.bot_context.approver]?.description}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<label className="mythic-eventing-step-switch-row">
|
||||
<span className="mythic-eventing-step-switch-copy">
|
||||
<span className="mythic-eventing-step-switch-title">Require user input</span>
|
||||
<span className="mythic-eventing-step-switch-subtitle">Collect key/value data and merge it into this step's runtime inputs.</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={userInteraction.input_required || userInteraction.inputs.length > 0}
|
||||
onChange={(event) => updateUserInteraction(event.target.checked ? {input_required: true} : {input_required: false, inputs: []})}
|
||||
color={"info"}
|
||||
inputProps={{ 'aria-label': 'require user input' }}
|
||||
/>
|
||||
</label>
|
||||
{(userInteraction.input_required || userInteraction.inputs.length > 0) &&
|
||||
<MythicTextField
|
||||
placeholder="Prompt shown with the input fields..."
|
||||
onChange={(name, value) => updateUserInteraction({input_prompt: value})}
|
||||
value={userInteraction.input_prompt}
|
||||
marginBottom="0px"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
{(userInteraction.input_required || userInteraction.inputs.length > 0) &&
|
||||
<div className="mythic-eventing-step-list">
|
||||
{userInteraction.inputs.length === 0 &&
|
||||
<EventingStepEmptyInline>No input fields configured.</EventingStepEmptyInline>
|
||||
}
|
||||
{userInteraction.inputs.map((input, index) => (
|
||||
<div className="mythic-eventing-step-list-item mythic-eventing-step-list-item-editable mythic-eventing-user-input-field-row" key={`user-interaction-input-${index}`}>
|
||||
<IconButton className="mythic-table-row-icon-action mythic-table-row-icon-action-hover-danger" size="small" onClick={() => removeInputField(index)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<div className="mythic-eventing-step-list-content">
|
||||
<div className="mythic-eventing-step-field-grid">
|
||||
<MythicTextField
|
||||
placeholder="Input name"
|
||||
onChange={(name, value) => updateInputField(index, "name", value)}
|
||||
value={input.name}
|
||||
marginBottom="0px"
|
||||
/>
|
||||
<FormControl sx={{ display: "inline-block", width: "100%" }} size="small">
|
||||
<TextField
|
||||
label="Type"
|
||||
select
|
||||
size="small"
|
||||
style={{ width: "100%" }}
|
||||
value={input.type}
|
||||
onChange={(event) => updateInputField(index, "type", event.target.value)}
|
||||
>
|
||||
{userInteractionInputTypes.map((type) => (
|
||||
<MenuItem key={`user-interaction-type-${type}`} value={type}>{type}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</FormControl>
|
||||
<MythicTextField
|
||||
placeholder="Description"
|
||||
onChange={(name, value) => updateInputField(index, "description", value)}
|
||||
value={input.description}
|
||||
marginBottom="0px"
|
||||
/>
|
||||
<MythicTextField
|
||||
placeholder="Default value"
|
||||
onChange={(name, value) => updateInputField(index, "default_value", value)}
|
||||
value={input.default_value}
|
||||
marginBottom="0px"
|
||||
/>
|
||||
</div>
|
||||
<label className="mythic-eventing-step-switch-row mythic-eventing-step-switch-row-compact">
|
||||
<span className="mythic-eventing-step-switch-copy">
|
||||
<span className="mythic-eventing-step-switch-title">Required</span>
|
||||
<span className="mythic-eventing-step-switch-subtitle">User must supply this value before the step resumes.</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={input.required}
|
||||
onChange={(event) => updateInputField(index, "required", event.target.checked)}
|
||||
color={"info"}
|
||||
inputProps={{ 'aria-label': 'required user input' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
{(userInteraction.input_required || userInteraction.inputs.length > 0) &&
|
||||
<Button className="mythic-table-row-action mythic-table-row-action-hover-success" onClick={addInputField} variant="outlined" startIcon={<AddCircleIcon fontSize="small" />}>
|
||||
Add input field
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const EventingStep = ({step, allSteps, updateStep, index, step1Data, updateStep1Data}) => {
|
||||
const [name, setName] = React.useState(step.name);
|
||||
const [description, setDescription] = React.useState(step.description);
|
||||
@@ -2476,6 +2707,7 @@ const EventingStep = ({step, allSteps, updateStep, index, step1Data, updateStep1
|
||||
const [dependsOn, setDependsOn] = React.useState([]);
|
||||
const [updatedActionOptions, setUpdatedActionOptions] = React.useState([]);
|
||||
const [continueOnError, setContinueOnError] = React.useState(step.continue_on_error || false);
|
||||
const [userInteraction, setUserInteraction] = React.useState(normalizeUserInteractionConfig(step.user_interaction));
|
||||
const [localInputOptions, setLocalInputOptions] = React.useState([]);
|
||||
const [inputEditorData, setInputEditorData] = React.useState(null);
|
||||
|
||||
@@ -2533,6 +2765,11 @@ const EventingStep = ({step, allSteps, updateStep, index, step1Data, updateStep1
|
||||
setContinueOnError(evt.target.checked)
|
||||
updateStep(index, "continue_on_error", evt.target.checked);
|
||||
}
|
||||
const onChangeUserInteraction = (nextConfig) => {
|
||||
const normalizedConfig = normalizeUserInteractionConfig(nextConfig);
|
||||
setUserInteraction(normalizedConfig);
|
||||
updateStep(index, "user_interaction", normalizedConfig);
|
||||
}
|
||||
const updateStepInputs = React.useCallback((nextInputs) => {
|
||||
setInputEditorData(nextInputs);
|
||||
updateStep(index, "inputs", nextInputs);
|
||||
@@ -2577,6 +2814,9 @@ const EventingStep = ({step, allSteps, updateStep, index, step1Data, updateStep1
|
||||
</div>
|
||||
<div className="mythic-eventing-step-config-summary-actions">
|
||||
<span className="mythic-eventing-step-action-chip">{selectedAction}</span>
|
||||
{hasUserInteractionConfig(userInteraction) &&
|
||||
<span className="mythic-eventing-step-action-chip mythic-eventing-step-action-chip-warning">user interaction</span>
|
||||
}
|
||||
<label className="mythic-eventing-step-switch-row">
|
||||
<span className="mythic-eventing-step-switch-copy">
|
||||
<span className="mythic-eventing-step-switch-title">Continue on error</span>
|
||||
@@ -2651,6 +2891,13 @@ const EventingStep = ({step, allSteps, updateStep, index, step1Data, updateStep1
|
||||
prevData={step?.outputs || []}/>
|
||||
</EventingStepConfigSection>
|
||||
</div>
|
||||
<EventingStepConfigSection
|
||||
className="mythic-eventing-step-config-section-wide"
|
||||
title="User interaction"
|
||||
description="Pause before execution to request approval, runtime input, or both."
|
||||
>
|
||||
<EventingStepUserInteraction config={userInteraction} onChange={onChangeUserInteraction} />
|
||||
</EventingStepConfigSection>
|
||||
<EventingStepConfigSection
|
||||
className="mythic-eventing-step-config-section-wide"
|
||||
title="Action data"
|
||||
@@ -2742,6 +2989,7 @@ const CreateEventingStep2 = ({finished, back, first, last, cancel, prevData, ste
|
||||
outputs: [],
|
||||
depends_on: [],
|
||||
environment: {},
|
||||
user_interaction: getDefaultUserInteractionConfig(),
|
||||
continue_on_error: false
|
||||
}]);
|
||||
}
|
||||
@@ -2928,6 +3176,9 @@ const CreateEventingStep3 = ({finished, back, first, last, cancel, prevData, ste
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
};
|
||||
if(hasUserInteractionConfig(step2Data[i].user_interaction)){
|
||||
stepData.user_interaction = normalizeUserInteractionConfig(step2Data[i].user_interaction);
|
||||
}
|
||||
for(let j = 0; j < step2Data[i].inputs.length; j++){
|
||||
if(step2Data[i].inputs[j].type === "custom"){
|
||||
stepData.inputs[step2Data[i].inputs[j].name] = step2Data[i].inputs[j].value;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, {} from 'react';
|
||||
import {EventingStatusChip, EventStepInstanceRenderDialog} from "./EventStepRender";
|
||||
import {
|
||||
EventingStatusChip,
|
||||
EventStepInstanceRenderDialog,
|
||||
EventStepUserInteractionDialog,
|
||||
eventingUserInteractionStatuses
|
||||
} from "./EventStepRender";
|
||||
import {toLocalTime} from "../../utilities/Time";
|
||||
import {MythicConfirmDialog} from '../../MythicComponents/MythicConfirmDialog';
|
||||
import { gql, useMutation } from '@apollo/client';
|
||||
@@ -16,6 +21,7 @@ import {snackActions} from "../../utilities/Snackbar";
|
||||
import {MythicDialog} from "../../MythicComponents/MythicDialog";
|
||||
import OpenInNewTwoToneIcon from '@mui/icons-material/OpenInNewTwoTone';
|
||||
import IosShareIcon from '@mui/icons-material/IosShare';
|
||||
import RuleTwoToneIcon from '@mui/icons-material/RuleTwoTone';
|
||||
import {copyStringToClipboard} from "../../utilities/Clipboard";
|
||||
import {ipCompare} from "../Callbacks/CallbacksTable";
|
||||
import MythicResizableGrid from "../../MythicComponents/MythicResizableGrid";
|
||||
@@ -115,6 +121,7 @@ function EventGroupInstancesTableMaterialReactTablePreMemo({eventgroups, me, set
|
||||
const [openRetryDialog, setOpenRetryDialog] = React.useState(false);
|
||||
const [openRunAgainDialog, setOpenRunAgainDialog] = React.useState(false);
|
||||
const [openEventStepRender, setOpenEventStepRender] = React.useState(false);
|
||||
const [openUserInteractionDialog, setOpenUserInteractionDialog] = React.useState(false);
|
||||
const [openInstanceDropdown, setOpenInstanceDropdown] = React.useState(false);
|
||||
const instanceDropdownRef = React.useRef({options: [], anchor: null});
|
||||
const selectedLocalInstanceID = React.useRef(0);
|
||||
@@ -182,6 +189,11 @@ function EventGroupInstancesTableMaterialReactTablePreMemo({eventgroups, me, set
|
||||
selectedLocalEventGroup.current = e.eventgroup;
|
||||
setOpenEventStepRender(true);
|
||||
}
|
||||
const openUserInteractionResponseDialog = (e) => {
|
||||
selectedLocalInstanceID.current = e.id;
|
||||
selectedLocalEventGroup.current = e.eventgroup;
|
||||
setOpenUserInteractionDialog(true);
|
||||
}
|
||||
const onSaveToClipboard = (e) => {
|
||||
let path = window.location.origin;
|
||||
path += `/new/eventing?eventgroup=${e.eventgroup.id}&eventgroupinstance=${e.id}`;
|
||||
@@ -231,6 +243,17 @@ function EventGroupInstancesTableMaterialReactTablePreMemo({eventgroups, me, set
|
||||
});
|
||||
}
|
||||
return [
|
||||
...(eventingUserInteractionStatuses.includes(row.status) ? [{
|
||||
name: "Respond to approval / input",
|
||||
type: "item",
|
||||
icon: <RuleTwoToneIcon style={eventingInstanceMenuIconStyle} />,
|
||||
className: "mythic-menu-item-hover-success",
|
||||
click: ({event}) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
openUserInteractionResponseDialog(row);
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
name: "Open graph in modal",
|
||||
type: "item",
|
||||
@@ -574,6 +597,18 @@ function EventGroupInstancesTableMaterialReactTablePreMemo({eventgroups, me, set
|
||||
/>}
|
||||
/>
|
||||
}
|
||||
{openUserInteractionDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="md" open={openUserInteractionDialog}
|
||||
onClose={() => {
|
||||
setOpenUserInteractionDialog(false);
|
||||
}}
|
||||
innerDialog={
|
||||
<EventStepUserInteractionDialog
|
||||
onClose={() => { setOpenUserInteractionDialog(false); }}
|
||||
selectedEventGroupInstance={selectedLocalInstanceID.current}
|
||||
/>}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {useCallback} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {gql, useQuery, useSubscription, useMutation, useLazyQuery} from '@apollo/client';
|
||||
import {gql, useQuery, useSubscription, useMutation, useLazyQuery, useReactiveVar} from '@apollo/client';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
@@ -60,6 +60,8 @@ import {getStringSize} from "../Callbacks/ResponseDisplayTable";
|
||||
import {MythicPageHeader} from "../../MythicComponents/MythicPageHeader";
|
||||
import {MythicEmptyState, MythicErrorState, MythicLoadingState} from "../../MythicComponents/MythicStateDisplay";
|
||||
import {MythicClientSideTablePagination, useMythicClientPagination} from "../../MythicComponents/MythicTablePagination";
|
||||
import TextField from '@mui/material/TextField';
|
||||
import {meState} from "../../../cache";
|
||||
|
||||
|
||||
const getEventSteps = gql`
|
||||
@@ -74,6 +76,7 @@ query GetEventSteps($eventgroup_id: Int!) {
|
||||
action_data
|
||||
inputs
|
||||
outputs
|
||||
user_interaction
|
||||
order
|
||||
}
|
||||
}
|
||||
@@ -89,6 +92,14 @@ subscription GetEventStepInstances($eventgroupinstance_id: Int!) {
|
||||
depends_on
|
||||
order
|
||||
action
|
||||
user_interaction
|
||||
}
|
||||
eventgroupinstance {
|
||||
operator {
|
||||
id
|
||||
username
|
||||
account_type
|
||||
}
|
||||
}
|
||||
eventgroupinstance_id
|
||||
created_at
|
||||
@@ -96,6 +107,10 @@ subscription GetEventStepInstances($eventgroupinstance_id: Int!) {
|
||||
end_timestamp
|
||||
status
|
||||
order
|
||||
user_interaction
|
||||
user_interaction_response
|
||||
user_interaction_resolved_by
|
||||
user_interaction_resolved_at
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -105,6 +120,7 @@ query getEventStepInstanceDetails($eventstepinstance_id: Int!){
|
||||
environment
|
||||
inputs
|
||||
outputs
|
||||
user_interaction
|
||||
id
|
||||
status
|
||||
updated_at
|
||||
@@ -112,6 +128,9 @@ query getEventStepInstanceDetails($eventstepinstance_id: Int!){
|
||||
order
|
||||
end_timestamp
|
||||
action_data
|
||||
user_interaction_response
|
||||
user_interaction_resolved_by
|
||||
user_interaction_resolved_at
|
||||
stdout
|
||||
stderr
|
||||
eventstep {
|
||||
@@ -121,6 +140,7 @@ query getEventStepInstanceDetails($eventstepinstance_id: Int!){
|
||||
name
|
||||
inputs
|
||||
outputs
|
||||
user_interaction
|
||||
environment
|
||||
depends_on
|
||||
}
|
||||
@@ -275,6 +295,7 @@ query getEventStepInformation($eventstep_id: Int!){
|
||||
action_data
|
||||
inputs
|
||||
outputs
|
||||
user_interaction
|
||||
order
|
||||
}
|
||||
}
|
||||
@@ -287,6 +308,47 @@ mutation retryFromEventStep($eventstepinstance_id: Int!, $retry_all_groups: Bool
|
||||
}
|
||||
}
|
||||
`;
|
||||
const submitEventStepUserInteractionMutation = gql`
|
||||
mutation submitEventStepUserInteraction($eventstepinstance_id: Int!, $approved: Boolean, $inputs: jsonb, $comment: String) {
|
||||
eventingStepUserInteractionSubmit(eventstepinstance_id: $eventstepinstance_id, approved: $approved, inputs: $inputs, comment: $comment) {
|
||||
status
|
||||
error
|
||||
}
|
||||
}
|
||||
`;
|
||||
const getEventStepInstancesForUserInteraction = gql`
|
||||
query getEventStepInstancesForUserInteraction($eventgroupinstance_id: Int!, $statuses: [String!]) {
|
||||
eventstepinstance(where: {eventgroupinstance_id: {_eq: $eventgroupinstance_id}, status: {_in: $statuses}}, order_by: {order: asc}) {
|
||||
id
|
||||
eventstep {
|
||||
name
|
||||
id
|
||||
description
|
||||
depends_on
|
||||
order
|
||||
action
|
||||
user_interaction
|
||||
}
|
||||
eventgroupinstance {
|
||||
operator {
|
||||
id
|
||||
username
|
||||
account_type
|
||||
}
|
||||
}
|
||||
eventgroupinstance_id
|
||||
created_at
|
||||
updated_at
|
||||
end_timestamp
|
||||
status
|
||||
order
|
||||
user_interaction
|
||||
user_interaction_response
|
||||
user_interaction_resolved_by
|
||||
user_interaction_resolved_at
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GetStatusSymbol = ({data}) => {
|
||||
let style = {marginRight: "5px"};
|
||||
@@ -311,6 +373,14 @@ export const GetStatusSymbol = ({data}) => {
|
||||
return (<MythicStyledTooltip title={"Skipped"}>
|
||||
<HideSourceIcon color={"info"} style={{...style}}/>
|
||||
</MythicStyledTooltip>)
|
||||
case "awaiting_approval":
|
||||
return (<MythicStyledTooltip title={"Awaiting approval"}>
|
||||
<TimelapseIcon color={"warning"} style={{...style}}/>
|
||||
</MythicStyledTooltip>)
|
||||
case "input_needed":
|
||||
return (<MythicStyledTooltip title={"Input needed"}>
|
||||
<InfoIconOutline color={"warning"} style={{...style}}/>
|
||||
</MythicStyledTooltip>)
|
||||
default:
|
||||
return (<MythicStyledTooltip title={"Waiting..."}>
|
||||
<PanoramaFishEyeIcon style={{...style}}/>
|
||||
@@ -376,6 +446,10 @@ const getStatusLabel = (status) => {
|
||||
return "Cancelled";
|
||||
case "skipped":
|
||||
return "Skipped";
|
||||
case "awaiting_approval":
|
||||
return "Awaiting approval";
|
||||
case "input_needed":
|
||||
return "Input needed";
|
||||
case "queued":
|
||||
return "Queued";
|
||||
default:
|
||||
@@ -394,6 +468,10 @@ const getStatusClass = (status) => {
|
||||
return "cancelled";
|
||||
case "skipped":
|
||||
return "skipped";
|
||||
case "awaiting_approval":
|
||||
return "awaiting_approval";
|
||||
case "input_needed":
|
||||
return "input_needed";
|
||||
default:
|
||||
return "waiting";
|
||||
}
|
||||
@@ -472,6 +550,375 @@ const EventingCodeBlock = ({value, emptyText="No data"}) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export const eventingUserInteractionStatuses = ["awaiting_approval", "input_needed"];
|
||||
export const eventStepNeedsUserInteraction = (step) => eventingUserInteractionStatuses.includes(step?.status);
|
||||
const getUserInteractionConfig = (step) => {
|
||||
const config = step?.user_interaction || step?.eventstep?.user_interaction || {};
|
||||
return config && typeof config === "object" ? config : {};
|
||||
}
|
||||
const normalizeUserInteractionInputs = (config) => {
|
||||
const inputs = config?.inputs || [];
|
||||
if(Array.isArray(inputs)){
|
||||
return inputs.filter((input) => input && typeof input === "object").map((input) => ({...input}));
|
||||
}
|
||||
if(inputs && typeof inputs === "object"){
|
||||
return Object.entries(inputs).map(([name, value]) => {
|
||||
if(value && typeof value === "object" && !Array.isArray(value)){
|
||||
return {name, ...value};
|
||||
}
|
||||
return {name, default_value: value};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const getInteractionInputDefault = (field) => {
|
||||
const defaultValue = field?.default_value;
|
||||
if(defaultValue === undefined || defaultValue === null){
|
||||
return "";
|
||||
}
|
||||
if(typeof defaultValue === "string"){
|
||||
return defaultValue;
|
||||
}
|
||||
return JSON.stringify(defaultValue);
|
||||
}
|
||||
const coerceUserInteractionInputs = (fields, values) => {
|
||||
const coercedValues = {};
|
||||
for(const field of fields){
|
||||
const name = field?.name || "";
|
||||
if(name === ""){
|
||||
continue;
|
||||
}
|
||||
const value = values[name];
|
||||
const type = (field?.type || "string").toLowerCase();
|
||||
if(value === undefined || value === null || value === ""){
|
||||
coercedValues[name] = value || "";
|
||||
continue;
|
||||
}
|
||||
if(type === "number"){
|
||||
const numberValue = Number(value);
|
||||
if(Number.isNaN(numberValue)){
|
||||
throw new Error(`${name} must be a number`);
|
||||
}
|
||||
coercedValues[name] = numberValue;
|
||||
}else if(type === "boolean"){
|
||||
coercedValues[name] = value === true || String(value).toLowerCase() === "true";
|
||||
}else if(type === "json"){
|
||||
coercedValues[name] = JSON.parse(value);
|
||||
}else{
|
||||
coercedValues[name] = value;
|
||||
}
|
||||
}
|
||||
return coercedValues;
|
||||
}
|
||||
const getUserInteractionStepName = (step) => step?.eventstep?.name || step?.name || `Step ${step?.id || ""}`.trim();
|
||||
const getUserInteractionStepAction = (step) => step?.eventstep?.action || step?.action || "";
|
||||
const getUserInteractionPrompt = (config, key, fallback) => {
|
||||
const value = config?.[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : fallback;
|
||||
}
|
||||
const getUserInteractionBotApprovalApprover = (config) => {
|
||||
const approver = config?.approval_policy?.bot_context?.approver;
|
||||
return approver === "lead" ? "lead" : "operator";
|
||||
}
|
||||
const getNextUserInteractionStepId = (steps, currentStepId) => {
|
||||
if(steps.length === 0){
|
||||
return 0;
|
||||
}
|
||||
const currentIndex = steps.findIndex((step) => `${step.id}` === `${currentStepId}`);
|
||||
if(currentIndex === -1 || currentIndex >= steps.length - 1){
|
||||
return steps[0].id;
|
||||
}
|
||||
return steps[currentIndex + 1].id;
|
||||
}
|
||||
export const EventStepUserInteractionDialog = ({onClose, selectedEventGroupInstance, selectedStep, steps}) => {
|
||||
const me = useReactiveVar(meState);
|
||||
const [resolvedStepIds, setResolvedStepIds] = React.useState([]);
|
||||
const [selectedStepId, setSelectedStepId] = React.useState(selectedStep?.id || 0);
|
||||
const shouldFetchSteps = !steps && selectedEventGroupInstance > 0;
|
||||
const {data, loading, refetch} = useQuery(getEventStepInstancesForUserInteraction, {
|
||||
variables: {eventgroupinstance_id: selectedEventGroupInstance, statuses: eventingUserInteractionStatuses},
|
||||
fetchPolicy: "no-cache",
|
||||
skip: !shouldFetchSteps,
|
||||
});
|
||||
const sourceSteps = React.useMemo(() => {
|
||||
if(steps){
|
||||
return steps;
|
||||
}
|
||||
if(data?.eventstepinstance){
|
||||
return data.eventstepinstance;
|
||||
}
|
||||
if(selectedStep){
|
||||
return [selectedStep];
|
||||
}
|
||||
return [];
|
||||
}, [steps, data?.eventstepinstance, selectedStep]);
|
||||
const waitingSteps = React.useMemo(() => {
|
||||
return sourceSteps.filter((step) => eventStepNeedsUserInteraction(step) && !resolvedStepIds.includes(step.id));
|
||||
}, [sourceSteps, resolvedStepIds]);
|
||||
React.useEffect(() => {
|
||||
if(selectedStep?.id && waitingSteps.some((step) => `${step.id}` === `${selectedStep.id}`)){
|
||||
setSelectedStepId(selectedStep.id);
|
||||
return;
|
||||
}
|
||||
if(waitingSteps.length > 0 && !waitingSteps.some((step) => `${step.id}` === `${selectedStepId}`)){
|
||||
setSelectedStepId(waitingSteps[0].id);
|
||||
}
|
||||
}, [selectedStep?.id, waitingSteps, selectedStepId]);
|
||||
const activeStep = waitingSteps.find((step) => `${step.id}` === `${selectedStepId}`) || waitingSteps[0];
|
||||
const config = React.useMemo(() => getUserInteractionConfig(activeStep), [activeStep]);
|
||||
const inputFields = React.useMemo(() => normalizeUserInteractionInputs(config), [config]);
|
||||
const approvalRequired = Boolean(config?.approval_required);
|
||||
const inputRequired = Boolean(config?.input_required) || inputFields.length > 0;
|
||||
const [inputValues, setInputValues] = React.useState({});
|
||||
const [comment, setComment] = React.useState("");
|
||||
const [submitInteraction] = useMutation(submitEventStepUserInteractionMutation, {
|
||||
onCompleted: (data) => {
|
||||
if(data.eventingStepUserInteractionSubmit.status === "success"){
|
||||
snackActions.success("Submitted eventing step response");
|
||||
}else{
|
||||
snackActions.error(data.eventingStepUserInteractionSubmit.error);
|
||||
}
|
||||
},
|
||||
onError: (data) => {
|
||||
console.log(data);
|
||||
snackActions.error("Failed to submit eventing step response");
|
||||
}
|
||||
});
|
||||
React.useEffect(() => {
|
||||
const nextValues = inputFields.reduce((prev, field) => {
|
||||
if(!field?.name){
|
||||
return prev;
|
||||
}
|
||||
return {...prev, [field.name]: getInteractionInputDefault(field)};
|
||||
}, {});
|
||||
setInputValues(nextValues);
|
||||
setComment("");
|
||||
}, [activeStep?.id, inputFields]);
|
||||
if(loading && waitingSteps.length === 0){
|
||||
return (
|
||||
<>
|
||||
<EventingDialogTitle title="User Interaction" subtitle="Loading waiting eventing steps..." />
|
||||
<DialogContent dividers={true} className="mythic-eventing-user-interaction-dialog-content">
|
||||
<MythicLoadingState compact title="Loading interaction request" description="Fetching waiting step details." minHeight={180} />
|
||||
</DialogContent>
|
||||
<DialogActions className="mythic-eventing-detail-dialog-actions">
|
||||
<Button className="mythic-table-row-action" onClick={onClose} variant="outlined">Close</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if(waitingSteps.length === 0 || !activeStep){
|
||||
return (
|
||||
<>
|
||||
<EventingDialogTitle title="User Interaction" subtitle="No eventing steps are currently waiting for approval or input." />
|
||||
<DialogContent dividers={true} className="mythic-eventing-user-interaction-dialog-content">
|
||||
<MythicEmptyState compact title="Nothing waiting" description="This workflow instance no longer has a step waiting for user interaction." minHeight={180} />
|
||||
</DialogContent>
|
||||
<DialogActions className="mythic-eventing-detail-dialog-actions">
|
||||
<Button className="mythic-table-row-action" onClick={onClose} variant="outlined">Close</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const runOperator = activeStep?.eventgroupinstance?.operator;
|
||||
const currentUserID = me?.user?.id || me?.user?.user_id;
|
||||
const botApprovalApprover = getUserInteractionBotApprovalApprover(config);
|
||||
const currentUserViewMode = me?.user?.view_mode || "";
|
||||
const currentUserIsLead = currentUserViewMode === "lead";
|
||||
const currentUserCanRespondToInteraction = currentUserIsLead || currentUserViewMode === "operator";
|
||||
const canRespond = currentUserCanRespondToInteraction && (!runOperator ||
|
||||
(runOperator?.account_type === "bot" && (
|
||||
!approvalRequired ||
|
||||
(botApprovalApprover === "lead" ? currentUserIsLead : currentUserCanRespondToInteraction)
|
||||
)) ||
|
||||
`${runOperator?.id}` === `${currentUserID}`);
|
||||
const submitResponse = (approved) => {
|
||||
let coercedInputs = {};
|
||||
try{
|
||||
const shouldSubmitInputs = inputRequired && (!approvalRequired || approved);
|
||||
if(shouldSubmitInputs){
|
||||
for(const field of inputFields){
|
||||
const name = field?.name || "";
|
||||
if(!name || !field?.required){
|
||||
continue;
|
||||
}
|
||||
const value = inputValues[name];
|
||||
if(value === undefined || value === null || value === ""){
|
||||
snackActions.error(`Missing required input ${name}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
coercedInputs = shouldSubmitInputs ? coerceUserInteractionInputs(inputFields, inputValues) : {};
|
||||
}catch(error){
|
||||
snackActions.error(error.message);
|
||||
return;
|
||||
}
|
||||
submitInteraction({
|
||||
variables: {
|
||||
eventstepinstance_id: activeStep.id,
|
||||
approved: approvalRequired ? approved : null,
|
||||
inputs: coercedInputs,
|
||||
comment,
|
||||
}
|
||||
}).then(async (result) => {
|
||||
if(result?.data?.eventingStepUserInteractionSubmit?.status !== "success"){
|
||||
return;
|
||||
}
|
||||
const remainingSteps = waitingSteps.filter((step) => step.id !== activeStep.id);
|
||||
setResolvedStepIds((prev) => [...prev, activeStep.id]);
|
||||
if(refetch){
|
||||
await refetch();
|
||||
}
|
||||
if(remainingSteps.length === 0){
|
||||
onClose?.();
|
||||
}else{
|
||||
setSelectedStepId(getNextUserInteractionStepId(remainingSteps, activeStep.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
const approvalPrompt = getUserInteractionPrompt(config, "approval_prompt", "Approval is required before this step can execute.");
|
||||
const inputPrompt = getUserInteractionPrompt(config, "input_prompt", "Additional input is required before this step can execute.");
|
||||
const stepName = getUserInteractionStepName(activeStep);
|
||||
const stepAction = getUserInteractionStepAction(activeStep);
|
||||
return (
|
||||
<>
|
||||
<EventingDialogTitle
|
||||
title="User Interaction"
|
||||
subtitle={`${waitingSteps.length} waiting step${waitingSteps.length === 1 ? "" : "s"} in this workflow instance`}
|
||||
statusData={activeStep}
|
||||
/>
|
||||
<DialogContent dividers={true} className="mythic-eventing-user-interaction-dialog-content">
|
||||
<div className={`mythic-eventing-user-interaction-modal ${waitingSteps.length === 1 ? "mythic-eventing-user-interaction-modal-single" : ""}`.trim()}>
|
||||
{waitingSteps.length > 1 &&
|
||||
<div className="mythic-eventing-user-interaction-step-picker">
|
||||
<div className="mythic-eventing-user-interaction-section-label">Waiting steps</div>
|
||||
{waitingSteps.map((step) => (
|
||||
<button
|
||||
className={`mythic-eventing-user-interaction-step-option ${`${step.id}` === `${activeStep.id}` ? "mythic-eventing-user-interaction-step-option-active" : ""}`.trim()}
|
||||
key={`user-interaction-step-${step.id}`}
|
||||
onClick={() => setSelectedStepId(step.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="mythic-eventing-user-interaction-step-name">{getUserInteractionStepName(step)}</span>
|
||||
<span className="mythic-eventing-user-interaction-step-meta">{getStatusLabel(step.status)}{getUserInteractionStepAction(step) ? ` - ${getUserInteractionStepAction(step)}` : ""}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
<div className="mythic-eventing-user-interaction-workspace">
|
||||
<div className="mythic-eventing-user-interaction-step-summary">
|
||||
<div>
|
||||
<div className="mythic-eventing-user-interaction-section-label">Step</div>
|
||||
<div className="mythic-eventing-user-interaction-title">{stepName}</div>
|
||||
</div>
|
||||
<div className="mythic-eventing-user-interaction-summary-chips">
|
||||
<EventingStatusChip data={activeStep} />
|
||||
{stepAction &&
|
||||
<span className="mythic-eventing-flow-node-action">{stepAction}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{approvalRequired &&
|
||||
<div className="mythic-eventing-user-interaction-section">
|
||||
<div className="mythic-eventing-user-interaction-section-title">Approval</div>
|
||||
<div className="mythic-eventing-user-interaction-prompt">{approvalPrompt}</div>
|
||||
{runOperator?.account_type === "bot" &&
|
||||
<div className="mythic-eventing-user-interaction-policy-note">
|
||||
{botApprovalApprover === "lead" ? "Requires operation lead approval." : "Operators and the operation lead can approve because this workflow is running as bot."}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{inputRequired &&
|
||||
<div className="mythic-eventing-user-interaction-section">
|
||||
<div className="mythic-eventing-user-interaction-section-title">Inputs</div>
|
||||
<div className="mythic-eventing-user-interaction-prompt">{inputPrompt}</div>
|
||||
{inputFields.length > 0 &&
|
||||
<div className="mythic-eventing-user-interaction-inputs">
|
||||
{inputFields.map((field, index) => (
|
||||
<div className="mythic-eventing-user-interaction-input-row" key={`${activeStep.id}-${field.name || index}`}>
|
||||
<div className="mythic-eventing-user-interaction-input-copy">
|
||||
<div className="mythic-eventing-user-interaction-input-name">
|
||||
{field.name || "Input"}
|
||||
{field.required &&
|
||||
<span className="mythic-eventing-user-interaction-required">required</span>
|
||||
}
|
||||
</div>
|
||||
<div className="mythic-eventing-user-interaction-input-description">
|
||||
{field.description || field.type || "string"}
|
||||
</div>
|
||||
</div>
|
||||
<TextField
|
||||
size="small"
|
||||
value={inputValues[field.name] || ""}
|
||||
onChange={(event) => setInputValues({...inputValues, [field.name]: event.target.value})}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div className="mythic-eventing-user-interaction-section">
|
||||
<div className="mythic-eventing-user-interaction-section-title">Comment</div>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Optional note for this response"
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<div className="mythic-eventing-user-interaction-permission">
|
||||
{!currentUserCanRespondToInteraction ? (
|
||||
"Spectators cannot respond to user interaction steps."
|
||||
) : !runOperator ? (
|
||||
"Permission will be checked when this response is submitted."
|
||||
) : canRespond ? (
|
||||
runOperator?.account_type === "bot" ? (
|
||||
approvalRequired && botApprovalApprover === "lead" ? "You can respond because you are the operation lead." : (
|
||||
approvalRequired ? "You can respond because your operation role can approve this step." : "Operators and the operation lead can respond because this workflow is running as bot."
|
||||
)
|
||||
) : `Waiting for ${runOperator?.username || "the run operator"}.`
|
||||
) : (
|
||||
runOperator?.account_type === "bot" && approvalRequired ? (
|
||||
botApprovalApprover === "lead" ? "Only the operation lead can approve this step." : "Only operators or the operation lead can approve this step."
|
||||
) : `Only ${runOperator?.username || "the run operator"} can respond.`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions className="mythic-eventing-detail-dialog-actions">
|
||||
{approvalRequired &&
|
||||
<Button
|
||||
className="mythic-table-row-action mythic-table-row-action-hover-warning"
|
||||
disabled={!canRespond}
|
||||
onClick={() => submitResponse(false)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
}
|
||||
<Button className="mythic-table-row-action" onClick={onClose} size="small" variant="outlined">
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="mythic-table-row-action mythic-table-row-action-hover-success"
|
||||
disabled={!canRespond}
|
||||
onClick={() => submitResponse(true)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
>
|
||||
{approvalRequired ? (inputRequired ? "Approve and Submit" : "Approve") : "Submit Input"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const EventingDetailSection = ({title, subtitle, count, actions, children, className = "", collapsible = false, defaultExpanded = false}) => {
|
||||
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
||||
const isExpanded = collapsible ? expanded : true;
|
||||
@@ -590,18 +1037,18 @@ function EventNode({data}) {
|
||||
<>
|
||||
<Handle type={"source"} position={sourcePosition}/>
|
||||
<div className={`mythic-eventing-flow-node mythic-eventing-flow-node-${getEventingStatusClass(data?.status)}`.trim()}>
|
||||
<Typography className="mythic-eventing-flow-node-title" title={data.name}>{data.name}</Typography>
|
||||
<div className="mythic-eventing-flow-node-main">
|
||||
<EventingStatusChip data={data}/>
|
||||
{data.status &&
|
||||
<GetTimeDuration data={data} />
|
||||
}
|
||||
</div>
|
||||
<Typography className="mythic-eventing-flow-node-title">{data.name}</Typography>
|
||||
<div className="mythic-eventing-flow-node-meta">
|
||||
{data.action &&
|
||||
<span className="mythic-eventing-flow-node-action">{data.action}</span>
|
||||
<span className="mythic-eventing-flow-node-action" title={data.action}>{data.action}</span>
|
||||
}
|
||||
</div>
|
||||
{data.status &&
|
||||
<div className="mythic-eventing-flow-node-meta">
|
||||
<GetTimeDuration data={data} customStyle={{float: "none", fontSize: "unset"}} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<Handle type={"target"} position={targetPosition}/>
|
||||
</>
|
||||
@@ -897,8 +1344,10 @@ function EventStepInstanceRender({selectedEventGroupInstance}) {
|
||||
const [nodes, setNodes] = React.useState([]);
|
||||
const [edges, setEdges] = React.useState([]);
|
||||
const currentEventStepInstance = React.useRef(0);
|
||||
const selectedUserInteractionStep = React.useRef(null);
|
||||
const [openEventStepInstanceDetails, setOpenEventStepInstanceDetails] = React.useState(false);
|
||||
const [openEventGroupInstanceDetails, setOpenEventGroupInstanceDetails] = React.useState(false);
|
||||
const [openUserInteractionDialog, setOpenUserInteractionDialog] = React.useState(false);
|
||||
const [graphData, setGraphData] = React.useState({nodes: [], edges: [], groups: []});
|
||||
const {fitView} = useReactFlow()
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
@@ -954,6 +1403,17 @@ function EventStepInstanceRender({selectedEventGroupInstance}) {
|
||||
}
|
||||
});
|
||||
const contextMenu = React.useMemo(() => {return [
|
||||
{
|
||||
title: 'Respond to approval / input',
|
||||
className: 'mythic-table-row-action-hover-success',
|
||||
shouldShow: function(node) {
|
||||
return eventStepNeedsUserInteraction(node);
|
||||
},
|
||||
onClick: function(node) {
|
||||
selectedUserInteractionStep.current = node;
|
||||
setOpenUserInteractionDialog(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'View Details',
|
||||
onClick: function(node) {
|
||||
@@ -1164,8 +1624,8 @@ function EventStepInstanceRender({selectedEventGroupInstance}) {
|
||||
</ReactFlow>
|
||||
{openContextMenu && typeof document !== "undefined" && createPortal(
|
||||
<div style={{...contextMenuCoord, position: "fixed"}} className="context-menu mythic-graph-context-menu">
|
||||
{contextMenu.map( (m) => (
|
||||
<Button key={m.title} className="context-menu-button mythic-graph-context-menu-button mythic-table-row-action mythic-table-row-action-hover-info" variant="outlined" onClick={() => {
|
||||
{contextMenu.filter((m) => !m.shouldShow || m.shouldShow(contextMenuNode.current)).map( (m) => (
|
||||
<Button key={m.title} className={`context-menu-button mythic-graph-context-menu-button mythic-table-row-action ${m.className || "mythic-table-row-action-hover-info"}`.trim()} variant="outlined" onClick={() => {
|
||||
m.onClick(contextMenuNode.current);
|
||||
setOpenContextMenu(false);
|
||||
}}>{m.title}</Button>
|
||||
@@ -1184,6 +1644,20 @@ function EventStepInstanceRender({selectedEventGroupInstance}) {
|
||||
/>}
|
||||
/>
|
||||
}
|
||||
{openUserInteractionDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="md" open={openUserInteractionDialog}
|
||||
onClose={() => {
|
||||
setOpenUserInteractionDialog(false);
|
||||
}}
|
||||
innerDialog={
|
||||
<EventStepUserInteractionDialog
|
||||
onClose={() => { setOpenUserInteractionDialog(false); }}
|
||||
selectedEventGroupInstance={selectedEventGroupInstance}
|
||||
selectedStep={selectedUserInteractionStep.current}
|
||||
steps={steps}
|
||||
/>}
|
||||
/>
|
||||
}
|
||||
{openEventGroupInstanceDetails &&
|
||||
<MythicDialog fullWidth={true} maxWidth="xl" open={openEventGroupInstanceDetails}
|
||||
onClose={() => {
|
||||
@@ -1319,6 +1793,7 @@ function EventStepInstanceDetailDialog({selectedEventStepInstance, onClose}) {
|
||||
<EventingMetadataPair label="Inputs" original={stepDefinition.inputs} instance={stepInstance.inputs} />
|
||||
<EventingMetadataPair label="Outputs" original={stepDefinition.outputs} instance={stepInstance.outputs} />
|
||||
<EventingMetadataPair label="Action data" original={stepDefinition.action_data} instance={stepInstance.action_data} />
|
||||
<EventingMetadataPair label="User interaction" original={stepDefinition.user_interaction} instance={stepInstance.user_interaction_response} originalLabel="Configured" instanceLabel="Response" />
|
||||
</div>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
@@ -1543,6 +2018,10 @@ function EventStepDetailDialog({selectedEventStep, onClose}) {
|
||||
<div className="mythic-eventing-metadata-panel-title">Action data</div>
|
||||
<EventingCodeBlock value={stepDefinition.action_data} />
|
||||
</div>
|
||||
<div className="mythic-eventing-metadata-panel">
|
||||
<div className="mythic-eventing-metadata-panel-title">User interaction</div>
|
||||
<EventingCodeBlock value={stepDefinition.user_interaction} />
|
||||
</div>
|
||||
</div>
|
||||
</EventingDetailSection>
|
||||
</DialogContent>
|
||||
|
||||
@@ -8923,6 +8923,17 @@ tspan {
|
||||
min-height: 1.65rem;
|
||||
padding: 0 0.55rem;
|
||||
}
|
||||
.mythic-eventing-step-action-chip-warning {
|
||||
background-color: var(--mythic-global-046);
|
||||
border-color: var(--mythic-global-104);
|
||||
color: var(--mythic-global-038);
|
||||
}
|
||||
.mythic-eventing-step-switch-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.mythic-eventing-step-switch-row {
|
||||
align-items: center;
|
||||
background-color: var(--mythic-global-231);
|
||||
@@ -8934,6 +8945,11 @@ tspan {
|
||||
min-width: 16rem;
|
||||
padding: 0.3rem 0.35rem 0.3rem 0.6rem;
|
||||
}
|
||||
.mythic-eventing-step-switch-row-compact {
|
||||
margin-top: 0.5rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.mythic-eventing-step-switch-copy {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
@@ -9048,6 +9064,24 @@ tspan {
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.mythic-eventing-step-user-interaction-section {
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
.mythic-eventing-step-user-interaction-section .mythic-eventing-step-switch-stack,
|
||||
.mythic-eventing-step-user-interaction-section .mythic-eventing-step-switch-row {
|
||||
width: 100%;
|
||||
}
|
||||
.mythic-eventing-step-user-interaction-section > .mythic-table-row-action {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.mythic-eventing-step-approval-policy-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.mythic-eventing-step-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -9069,10 +9103,20 @@ tspan {
|
||||
.mythic-eventing-step-list-item-editable {
|
||||
display: block;
|
||||
}
|
||||
.mythic-eventing-step-list-item-editable.mythic-eventing-user-input-field-row {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
}
|
||||
.mythic-eventing-step-list-item > .MuiIconButton-root {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
.mythic-eventing-user-input-field-row > .MuiIconButton-root {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.mythic-eventing-user-input-field-row .mythic-eventing-step-field-grid {
|
||||
width: 100%;
|
||||
}
|
||||
.mythic-eventing-step-list-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -9765,11 +9809,12 @@ tspan {
|
||||
color: var(--mythic-global-002);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.42rem;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem 0.58rem;
|
||||
padding: 0.58rem 0.64rem;
|
||||
width: 100%;
|
||||
}
|
||||
.mythic-eventing-flow-node-success {
|
||||
@@ -9778,6 +9823,10 @@ tspan {
|
||||
.mythic-eventing-flow-node-running {
|
||||
border-color: var(--mythic-global-384);
|
||||
}
|
||||
.mythic-eventing-flow-node-awaiting_approval,
|
||||
.mythic-eventing-flow-node-input_needed {
|
||||
border-color: var(--mythic-global-104);
|
||||
}
|
||||
.mythic-eventing-flow-node-error {
|
||||
border-color: var(--mythic-global-354);
|
||||
}
|
||||
@@ -9792,23 +9841,28 @@ tspan {
|
||||
border-color: var(--mythic-global-007);
|
||||
}
|
||||
.mythic-eventing-flow-node-main {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.34rem;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
.mythic-eventing-flow-node-title {
|
||||
color: var(--mythic-global-002);
|
||||
font-size: 0.82rem !important;
|
||||
display: -webkit-box;
|
||||
font-size: 0.84rem !important;
|
||||
font-weight: 850 !important;
|
||||
line-height: 1.18 !important;
|
||||
margin: 0 !important;
|
||||
max-width: 15rem;
|
||||
min-height: 1.95rem;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
.mythic-eventing-flow-node-meta {
|
||||
align-items: center;
|
||||
@@ -9817,7 +9871,7 @@ tspan {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.42rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.mythic-eventing-flow-node-meta .MuiTypography-root {
|
||||
color: var(--mythic-global-025) !important;
|
||||
@@ -9833,11 +9887,16 @@ tspan {
|
||||
border: 1px solid var(--mythic-global-010);
|
||||
border-radius: var(--mythic-global-008);
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
max-width: 100%;
|
||||
min-height: 1.28rem;
|
||||
overflow: hidden;
|
||||
padding: 0 0.38rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mythic-eventing-status-chip {
|
||||
align-items: center;
|
||||
@@ -9846,17 +9905,27 @@ tspan {
|
||||
border-radius: var(--mythic-global-008);
|
||||
color: var(--mythic-global-025);
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
max-width: 100%;
|
||||
min-height: 1.45rem;
|
||||
min-width: 0;
|
||||
padding: 0 0.45rem;
|
||||
width: fit-content;
|
||||
}
|
||||
.mythic-eventing-status-chip .MuiSvgIcon-root {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.95rem !important;
|
||||
margin: 0 0.28rem 0 0 !important;
|
||||
}
|
||||
.mythic-eventing-status-chip span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mythic-eventing-status-chip-success {
|
||||
background-color: var(--mythic-global-388);
|
||||
border-color: var(--mythic-global-389);
|
||||
@@ -9867,6 +9936,12 @@ tspan {
|
||||
border-color: var(--mythic-global-368);
|
||||
color: var(--mythic-global-023);
|
||||
}
|
||||
.mythic-eventing-status-chip-awaiting_approval,
|
||||
.mythic-eventing-status-chip-input_needed {
|
||||
background-color: var(--mythic-global-046);
|
||||
border-color: var(--mythic-global-104);
|
||||
color: var(--mythic-global-038);
|
||||
}
|
||||
.mythic-eventing-status-chip-error {
|
||||
background-color: var(--mythic-global-390);
|
||||
border-color: var(--mythic-global-391);
|
||||
@@ -9888,6 +9963,184 @@ tspan {
|
||||
border-color: var(--mythic-global-362);
|
||||
color: var(--mythic-global-025);
|
||||
}
|
||||
.mythic-eventing-user-interaction-dialog-content {
|
||||
background-color: var(--mythic-global-318);
|
||||
}
|
||||
.mythic-eventing-user-interaction-modal {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
grid-template-columns: minmax(11rem, 15rem) minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
.mythic-eventing-user-interaction-modal-single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.mythic-eventing-user-interaction-step-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.38rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.mythic-eventing-user-interaction-step-option {
|
||||
background-color: var(--mythic-global-009);
|
||||
border: 1px solid var(--mythic-global-010);
|
||||
border-radius: var(--mythic-global-008);
|
||||
color: var(--mythic-global-002);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
padding: 0.52rem 0.58rem;
|
||||
text-align: left;
|
||||
}
|
||||
.mythic-eventing-user-interaction-step-option:hover,
|
||||
.mythic-eventing-user-interaction-step-option-active {
|
||||
border-color: var(--mythic-global-104);
|
||||
box-shadow: inset 3px 0 0 var(--mythic-global-104);
|
||||
}
|
||||
.mythic-eventing-user-interaction-step-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mythic-eventing-user-interaction-step-meta {
|
||||
color: var(--mythic-global-025);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mythic-eventing-user-interaction-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.mythic-eventing-user-interaction-step-summary,
|
||||
.mythic-eventing-user-interaction-section {
|
||||
background-color: var(--mythic-global-009);
|
||||
border: 1px solid var(--mythic-global-010);
|
||||
border-radius: var(--mythic-global-008);
|
||||
min-width: 0;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.mythic-eventing-user-interaction-step-summary {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.mythic-eventing-user-interaction-summary-chips {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
.mythic-eventing-user-interaction-section-title {
|
||||
color: var(--mythic-global-002);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 850;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.mythic-eventing-user-interaction-section-label {
|
||||
color: var(--mythic-global-025);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.35rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mythic-eventing-user-interaction-title {
|
||||
color: var(--mythic-global-002);
|
||||
font-size: 1rem;
|
||||
font-weight: 850;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mythic-eventing-user-interaction-prompt,
|
||||
.mythic-eventing-user-interaction-permission {
|
||||
color: var(--mythic-global-025);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
margin-top: 0.18rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mythic-eventing-user-interaction-prompt {
|
||||
color: var(--mythic-global-002);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.mythic-eventing-user-interaction-policy-note {
|
||||
color: var(--mythic-global-025);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
.mythic-eventing-user-interaction-required {
|
||||
background-color: var(--mythic-global-046);
|
||||
border: 1px solid var(--mythic-global-104);
|
||||
border-radius: var(--mythic-global-008);
|
||||
color: var(--mythic-global-038);
|
||||
display: inline-flex;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
margin-left: 0.38rem;
|
||||
padding: 0.18rem 0.3rem;
|
||||
}
|
||||
.mythic-eventing-user-interaction-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.58rem;
|
||||
margin-top: 0.62rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.mythic-eventing-user-interaction-input-row {
|
||||
align-items: flex-start;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: minmax(9rem, 0.45fr) minmax(12rem, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
.mythic-eventing-user-interaction-input-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
padding-top: 0.28rem;
|
||||
}
|
||||
.mythic-eventing-user-interaction-input-name {
|
||||
color: var(--mythic-global-002);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mythic-eventing-user-interaction-input-description {
|
||||
color: var(--mythic-global-025);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.mythic-eventing-user-interaction-modal,
|
||||
.mythic-eventing-user-interaction-input-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.mythic-eventing-detail-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -427,6 +427,15 @@ type Mutation {
|
||||
): eventingTriggerRetryFromStepOutput
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
eventingStepUserInteractionSubmit(
|
||||
approved: Boolean
|
||||
comment: String
|
||||
eventstepinstance_id: Int!
|
||||
inputs: jsonb
|
||||
): eventingStepUserInteractionSubmitOutput
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
eventingTriggerRunAgain(
|
||||
eventgroupinstance_id: Int!
|
||||
@@ -1316,6 +1325,11 @@ type eventingTriggerRetryFromStepOutput {
|
||||
error: String
|
||||
}
|
||||
|
||||
type eventingStepUserInteractionSubmitOutput {
|
||||
status: String!
|
||||
error: String
|
||||
}
|
||||
|
||||
type testProxyOutput {
|
||||
status: String!
|
||||
error: String
|
||||
|
||||
@@ -611,6 +611,17 @@ actions:
|
||||
- role: mythic_admin
|
||||
- role: developer
|
||||
comment: Manually retry an eventgroup instance from a specific instance step by ID
|
||||
- name: eventingStepUserInteractionSubmit
|
||||
definition:
|
||||
kind: synchronous
|
||||
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/eventing_step_user_interaction_submit_webhook'
|
||||
forward_client_headers: true
|
||||
permissions:
|
||||
- role: operator
|
||||
- role: operation_admin
|
||||
- role: mythic_admin
|
||||
- role: developer
|
||||
comment: Submit approval or runtime input for an eventing step instance waiting on user interaction
|
||||
- name: eventingTriggerRunAgain
|
||||
definition:
|
||||
kind: synchronous
|
||||
@@ -1116,6 +1127,7 @@ custom_types:
|
||||
- name: containerUploadFileOutput
|
||||
- name: containerWriteFileOutput
|
||||
- name: eventingTriggerRetryFromStepOutput
|
||||
- name: eventingStepUserInteractionSubmitOutput
|
||||
- name: testProxyOutput
|
||||
- name: eventingTestFileOutput
|
||||
- name: getOperatorPreferencesOutput
|
||||
|
||||
@@ -34,6 +34,7 @@ select_permissions:
|
||||
- environment
|
||||
- inputs
|
||||
- outputs
|
||||
- user_interaction
|
||||
- action
|
||||
- description
|
||||
- name
|
||||
@@ -59,6 +60,7 @@ select_permissions:
|
||||
- environment
|
||||
- inputs
|
||||
- outputs
|
||||
- user_interaction
|
||||
- action
|
||||
- description
|
||||
- name
|
||||
@@ -84,6 +86,7 @@ select_permissions:
|
||||
- environment
|
||||
- inputs
|
||||
- outputs
|
||||
- user_interaction
|
||||
- action
|
||||
- description
|
||||
- name
|
||||
@@ -109,6 +112,7 @@ select_permissions:
|
||||
- environment
|
||||
- inputs
|
||||
- outputs
|
||||
- user_interaction
|
||||
- action
|
||||
- description
|
||||
- name
|
||||
|
||||
@@ -93,12 +93,16 @@ select_permissions:
|
||||
- environment
|
||||
- inputs
|
||||
- outputs
|
||||
- user_interaction
|
||||
- user_interaction_response
|
||||
- status
|
||||
- stderr
|
||||
- stdout
|
||||
- created_at
|
||||
- end_timestamp
|
||||
- updated_at
|
||||
- user_interaction_resolved_at
|
||||
- user_interaction_resolved_by
|
||||
filter:
|
||||
_and:
|
||||
- operation_id:
|
||||
@@ -120,12 +124,16 @@ select_permissions:
|
||||
- environment
|
||||
- inputs
|
||||
- outputs
|
||||
- user_interaction
|
||||
- user_interaction_response
|
||||
- status
|
||||
- stderr
|
||||
- stdout
|
||||
- created_at
|
||||
- end_timestamp
|
||||
- updated_at
|
||||
- user_interaction_resolved_at
|
||||
- user_interaction_resolved_by
|
||||
filter:
|
||||
_and:
|
||||
- operation_id:
|
||||
@@ -147,12 +155,16 @@ select_permissions:
|
||||
- environment
|
||||
- inputs
|
||||
- outputs
|
||||
- user_interaction
|
||||
- user_interaction_response
|
||||
- status
|
||||
- stderr
|
||||
- stdout
|
||||
- created_at
|
||||
- end_timestamp
|
||||
- updated_at
|
||||
- user_interaction_resolved_at
|
||||
- user_interaction_resolved_by
|
||||
filter:
|
||||
_and:
|
||||
- operation_id:
|
||||
@@ -174,12 +186,16 @@ select_permissions:
|
||||
- environment
|
||||
- inputs
|
||||
- outputs
|
||||
- user_interaction
|
||||
- user_interaction_response
|
||||
- status
|
||||
- stderr
|
||||
- stdout
|
||||
- created_at
|
||||
- end_timestamp
|
||||
- updated_at
|
||||
- user_interaction_resolved_at
|
||||
- user_interaction_resolved_by
|
||||
filter:
|
||||
_and:
|
||||
- operation_id:
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
var DB *sqlx.DB
|
||||
var currentMigrationVersion int64 = 3003020
|
||||
var currentMigrationVersion int64 = 3003021
|
||||
|
||||
// initial structs made with './tables-to-go -u mythic_user -p [password here] -h [ip here] -v -d mythic_db -of output -pn database_structs'
|
||||
// package pulled from https://github.com/fraenky8/tables-to-go
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
|
||||
alter table "public"."eventstep"
|
||||
add column if not exists "user_interaction" jsonb not null default jsonb_build_object();
|
||||
|
||||
alter table "public"."eventstepinstance"
|
||||
add column if not exists "user_interaction" jsonb not null default jsonb_build_object(),
|
||||
add column if not exists "user_interaction_response" jsonb not null default jsonb_build_object(),
|
||||
add column if not exists "user_interaction_resolved_by" integer,
|
||||
add column if not exists "user_interaction_resolved_at" timestamp without time zone;
|
||||
|
||||
alter table "public"."eventstepinstance" drop constraint if exists "eventstepinstance_user_interaction_resolved_by_fkey";
|
||||
alter table "public"."eventstepinstance" add constraint "eventstepinstance_user_interaction_resolved_by_fkey"
|
||||
foreign key ("user_interaction_resolved_by") references "public"."operator"("id") on update restrict on delete restrict not valid;
|
||||
alter table "public"."eventstepinstance" validate constraint "eventstepinstance_user_interaction_resolved_by_fkey";
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
alter table "public"."eventstepinstance" drop constraint if exists "eventstepinstance_user_interaction_resolved_by_fkey";
|
||||
|
||||
alter table "public"."eventstepinstance"
|
||||
drop column if exists "user_interaction_resolved_at",
|
||||
drop column if exists "user_interaction_resolved_by",
|
||||
drop column if exists "user_interaction_response",
|
||||
drop column if exists "user_interaction";
|
||||
|
||||
alter table "public"."eventstep"
|
||||
drop column if exists "user_interaction";
|
||||
@@ -18,29 +18,34 @@ type EventStep struct {
|
||||
Environment MythicJSONText `db:"environment" json:"environment" toml:"environment" yaml:"environment,flow"`
|
||||
Inputs MythicJSONText `db:"inputs" json:"inputs" toml:"inputs" yaml:"inputs,flow"`
|
||||
Outputs MythicJSONText `db:"outputs" json:"outputs" toml:"outputs" yaml:"outputs,flow"`
|
||||
UserInteraction MythicJSONText `db:"user_interaction" json:"user_interaction" toml:"user_interaction" yaml:"user_interaction,flow"`
|
||||
Order int `db:"order" json:"order" toml:"order" yaml:"order"`
|
||||
ContinueOnError bool `db:"continue_on_error" json:"continue_on_error" toml:"continue_on_error" yaml:"continue_on_error"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"`
|
||||
}
|
||||
|
||||
type EventStepInstance struct {
|
||||
ID int `db:"id" json:"id" toml:"id" yaml:"id"`
|
||||
EventGroupInstanceID int `db:"eventgroupinstance_id" json:"eventgroupinstance_id" toml:"eventgroupinstance_id" yaml:"eventgroupinstance_id"`
|
||||
EventStepID int `db:"eventstep_id" json:"eventstep_id" toml:"eventstep_id" yaml:"eventstep_id"`
|
||||
OperatorID int `db:"operator_id" json:"operator_id" toml:"operator_id" yaml:"operator_id"`
|
||||
OperationID int `db:"operation_id" json:"operation_id" toml:"operation_id" yaml:"operation_id"`
|
||||
Environment MythicJSONText `db:"environment" json:"environment" toml:"environment" yaml:"environment,flow"`
|
||||
Inputs MythicJSONText `db:"inputs" json:"inputs" toml:"inputs" yaml:"inputs,flow"`
|
||||
Outputs MythicJSONText `db:"outputs" json:"outputs" toml:"outputs" yaml:"outputs,flow"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"`
|
||||
EndTimestamp sql.NullTime `db:"end_timestamp" json:"end_timestamp" toml:"end_timestamp" yaml:"end_timestamp"`
|
||||
Status string `db:"status" json:"status" toml:"status" yaml:"status"`
|
||||
Stdout string `db:"stdout" json:"stdout" toml:"stdout" yaml:"stdout"`
|
||||
Stderr string `db:"stderr" json:"stderr" toml:"stderr" yaml:"stderr"`
|
||||
Order int `db:"order" json:"order" toml:"order" yaml:"order"`
|
||||
ContinueOnError bool `db:"continue_on_error" json:"continue_on_error" toml:"continue_on_error" yaml:"continue_on_error"`
|
||||
ActionData MythicJSONText `db:"action_data" json:"action_data" toml:"action_data" yaml:"action_data,flow"`
|
||||
EventStep EventStep `db:"eventstep"`
|
||||
EventGroupInstance EventGroupInstance `db:"eventgroupinstance"`
|
||||
ID int `db:"id" json:"id" toml:"id" yaml:"id"`
|
||||
EventGroupInstanceID int `db:"eventgroupinstance_id" json:"eventgroupinstance_id" toml:"eventgroupinstance_id" yaml:"eventgroupinstance_id"`
|
||||
EventStepID int `db:"eventstep_id" json:"eventstep_id" toml:"eventstep_id" yaml:"eventstep_id"`
|
||||
OperatorID int `db:"operator_id" json:"operator_id" toml:"operator_id" yaml:"operator_id"`
|
||||
OperationID int `db:"operation_id" json:"operation_id" toml:"operation_id" yaml:"operation_id"`
|
||||
Environment MythicJSONText `db:"environment" json:"environment" toml:"environment" yaml:"environment,flow"`
|
||||
Inputs MythicJSONText `db:"inputs" json:"inputs" toml:"inputs" yaml:"inputs,flow"`
|
||||
Outputs MythicJSONText `db:"outputs" json:"outputs" toml:"outputs" yaml:"outputs,flow"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"`
|
||||
EndTimestamp sql.NullTime `db:"end_timestamp" json:"end_timestamp" toml:"end_timestamp" yaml:"end_timestamp"`
|
||||
Status string `db:"status" json:"status" toml:"status" yaml:"status"`
|
||||
Stdout string `db:"stdout" json:"stdout" toml:"stdout" yaml:"stdout"`
|
||||
Stderr string `db:"stderr" json:"stderr" toml:"stderr" yaml:"stderr"`
|
||||
Order int `db:"order" json:"order" toml:"order" yaml:"order"`
|
||||
ContinueOnError bool `db:"continue_on_error" json:"continue_on_error" toml:"continue_on_error" yaml:"continue_on_error"`
|
||||
ActionData MythicJSONText `db:"action_data" json:"action_data" toml:"action_data" yaml:"action_data,flow"`
|
||||
UserInteraction MythicJSONText `db:"user_interaction" json:"user_interaction" toml:"user_interaction" yaml:"user_interaction,flow"`
|
||||
UserInteractionResponse MythicJSONText `db:"user_interaction_response" json:"user_interaction_response" toml:"user_interaction_response" yaml:"user_interaction_response,flow"`
|
||||
UserInteractionResolvedBy sql.NullInt64 `db:"user_interaction_resolved_by" json:"user_interaction_resolved_by" toml:"user_interaction_resolved_by" yaml:"user_interaction_resolved_by"`
|
||||
UserInteractionResolvedAt sql.NullTime `db:"user_interaction_resolved_at" json:"user_interaction_resolved_at" toml:"user_interaction_resolved_at" yaml:"user_interaction_resolved_at"`
|
||||
EventStep EventStep `db:"eventstep"`
|
||||
EventGroupInstance EventGroupInstance `db:"eventgroupinstance"`
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
EventGroupInstanceStatusRunning = "running"
|
||||
EventGroupInstanceStatusCancelled = "cancelled"
|
||||
EventGroupInstanceStatusSuccess = "success"
|
||||
EventGroupInstanceStatusError = "error"
|
||||
EventGroupInstanceStatusQueued = "queued"
|
||||
EventGroupInstanceStatusSkipped = "skipped"
|
||||
EventGroupInstanceStatusRunning = "running"
|
||||
EventGroupInstanceStatusCancelled = "cancelled"
|
||||
EventGroupInstanceStatusSuccess = "success"
|
||||
EventGroupInstanceStatusError = "error"
|
||||
EventGroupInstanceStatusQueued = "queued"
|
||||
EventGroupInstanceStatusSkipped = "skipped"
|
||||
EventGroupInstanceStatusAwaitingApproval = "awaiting_approval"
|
||||
EventGroupInstanceStatusInputNeeded = "input_needed"
|
||||
)
|
||||
|
||||
func SaveEventGroup(eventGroup *databaseStructs.EventGroup, currentOperatorOperation *databaseStructs.Operatoroperation) error {
|
||||
@@ -121,8 +123,8 @@ func SaveEventGroup(eventGroup *databaseStructs.EventGroup, currentOperatorOpera
|
||||
eventGroup.Steps[i].EventGroup = eventGroup.ID
|
||||
eventGroup.Steps[i].OperatorID = currentOperatorOperation.CurrentOperator.ID
|
||||
statement, err = database.DB.PrepareNamed(`INSERT INTO eventstep
|
||||
(operator_id, operation_id, "name", description, eventgroup_id, environment, depends_on, action, action_data, inputs, outputs, "order", continue_on_error)
|
||||
VALUES (:operator_id, :operation_id,:name, :description, :eventgroup_id, :environment, :depends_on, :action, :action_data, :inputs, :outputs, :order, :continue_on_error )
|
||||
(operator_id, operation_id, "name", description, eventgroup_id, environment, depends_on, action, action_data, inputs, outputs, user_interaction, "order", continue_on_error)
|
||||
VALUES (:operator_id, :operation_id,:name, :description, :eventgroup_id, :environment, :depends_on, :action, :action_data, :inputs, :outputs, :user_interaction, :order, :continue_on_error )
|
||||
RETURNING id`)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to create statement for saving eventstep")
|
||||
@@ -496,6 +498,7 @@ func CreateEventGroupInstance(eventGroupId int, trigger string, triggeringOperat
|
||||
EventStepID: eventGroupSteps[i].ID,
|
||||
Inputs: eventGroupSteps[i].Inputs,
|
||||
Outputs: eventGroupSteps[i].Outputs,
|
||||
UserInteraction: eventGroupSteps[i].UserInteraction,
|
||||
Status: EventGroupInstanceStatusQueued,
|
||||
Order: eventGroupSteps[i].Order,
|
||||
ContinueOnError: eventGroupSteps[i].ContinueOnError,
|
||||
@@ -508,8 +511,8 @@ func CreateEventGroupInstance(eventGroupId int, trigger string, triggeringOperat
|
||||
}
|
||||
eventStepInstance.Environment = GetMythicJSONTextFromStruct(eventGroupEnv)
|
||||
_, err = database.DB.NamedExec(`INSERT INTO eventstepinstance
|
||||
(eventgroupinstance_id, operator_id, operation_id, eventstep_id, inputs, outputs, status, "order", environment, continue_on_error)
|
||||
VALUES (:eventgroupinstance_id, :operator_id, :operation_id, :eventstep_id, :inputs, :outputs, :status, :order, :environment, :continue_on_error)`,
|
||||
(eventgroupinstance_id, operator_id, operation_id, eventstep_id, inputs, outputs, user_interaction, status, "order", environment, continue_on_error)
|
||||
VALUES (:eventgroupinstance_id, :operator_id, :operation_id, :eventstep_id, :inputs, :outputs, :user_interaction, :status, :order, :environment, :continue_on_error)`,
|
||||
eventStepInstance)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to create new eventStepInstance")
|
||||
@@ -558,7 +561,10 @@ func updateAllRemainingStepInstances(eventGroupInstanceId int, status string) er
|
||||
return err
|
||||
}
|
||||
for i, _ := range eventStepInstances {
|
||||
if eventStepInstances[i].Status == EventGroupInstanceStatusQueued || eventStepInstances[i].Status == EventGroupInstanceStatusRunning {
|
||||
if eventStepInstances[i].Status == EventGroupInstanceStatusQueued ||
|
||||
eventStepInstances[i].Status == EventGroupInstanceStatusRunning ||
|
||||
eventStepInstances[i].Status == EventGroupInstanceStatusAwaitingApproval ||
|
||||
eventStepInstances[i].Status == EventGroupInstanceStatusInputNeeded {
|
||||
eventStepInstances[i].Status = status
|
||||
eventStepInstances[i].EndTimestamp.Time = time.Now().UTC()
|
||||
eventStepInstances[i].EndTimestamp.Valid = true
|
||||
|
||||
@@ -64,6 +64,7 @@ var (
|
||||
TriggerResponseInterceptResponse = "response_intercept_response"
|
||||
TriggerCallbackCheckin = "callback_checkin"
|
||||
TriggerTagCreate = "tag_create"
|
||||
TriggerStepUserInteractionSubmit = "step_user_interaction_submit"
|
||||
)
|
||||
|
||||
// when to trigger a new workflow
|
||||
@@ -163,6 +164,7 @@ func Ingest(fileContents []byte) (databaseStructs.EventGroup, error) {
|
||||
databaseEventStep.ActionData = GetMythicJSONTextFromStruct(eventGroup.Steps[i].ActionData)
|
||||
databaseEventStep.Inputs = GetMythicJSONTextFromStruct(eventGroup.Steps[i].Inputs)
|
||||
databaseEventStep.Outputs = GetMythicJSONTextFromStruct(eventGroup.Steps[i].Outputs)
|
||||
databaseEventStep.UserInteraction = GetMythicJSONTextFromStruct(eventGroup.Steps[i].UserInteraction)
|
||||
databaseEventStep.ContinueOnError = eventGroup.Steps[i].ContinueOnError
|
||||
databaseEventGroup.Steps = append(databaseEventGroup.Steps, databaseEventStep)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type EventStep struct {
|
||||
Environment map[string]interface{} `db:"environment" json:"environment" toml:"environment" yaml:"environment"`
|
||||
Inputs map[string]interface{} `db:"inputs" json:"inputs" toml:"inputs" yaml:"inputs"`
|
||||
Outputs map[string]interface{} `db:"outputs" json:"outputs" toml:"outputs" yaml:"outputs"`
|
||||
UserInteraction map[string]interface{} `db:"user_interaction" json:"user_interaction,omitempty" toml:"user_interaction,omitempty" yaml:"user_interaction,omitempty"`
|
||||
Order int `db:"order" json:"-" toml:"-" yaml:"-"`
|
||||
ContinueOnError bool `db:"continue_on_error" json:"continue_on_error" toml:"continue_on_error" yaml:"continue_on_error"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package eventing
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
UserInteractionApproverOperator = "operator"
|
||||
UserInteractionApproverLead = "lead"
|
||||
)
|
||||
|
||||
func UserInteractionApprovalRequired(config map[string]interface{}) bool {
|
||||
if value, ok := config["approval_required"]; ok {
|
||||
return userInteractionBool(value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func UserInteractionInputRequired(config map[string]interface{}) bool {
|
||||
if value, ok := config["input_required"]; ok && userInteractionBool(value) {
|
||||
return true
|
||||
}
|
||||
return len(UserInteractionInputs(config)) > 0
|
||||
}
|
||||
|
||||
func UserInteractionHasRequirements(config map[string]interface{}) bool {
|
||||
return UserInteractionApprovalRequired(config) || UserInteractionInputRequired(config)
|
||||
}
|
||||
|
||||
func UserInteractionBotApprovalApprover(config map[string]interface{}) string {
|
||||
approvalPolicy, ok := config["approval_policy"].(map[string]interface{})
|
||||
if !ok {
|
||||
return UserInteractionApproverOperator
|
||||
}
|
||||
botContext, ok := approvalPolicy["bot_context"].(map[string]interface{})
|
||||
if !ok {
|
||||
return UserInteractionApproverOperator
|
||||
}
|
||||
approver, ok := botContext["approver"].(string)
|
||||
if !ok {
|
||||
return UserInteractionApproverOperator
|
||||
}
|
||||
switch strings.ToLower(approver) {
|
||||
case UserInteractionApproverLead:
|
||||
return UserInteractionApproverLead
|
||||
default:
|
||||
return UserInteractionApproverOperator
|
||||
}
|
||||
}
|
||||
|
||||
func UserInteractionInputs(config map[string]interface{}) []map[string]interface{} {
|
||||
inputs := []map[string]interface{}{}
|
||||
inputValue, ok := config["inputs"]
|
||||
if !ok || inputValue == nil {
|
||||
return inputs
|
||||
}
|
||||
switch typedInputs := inputValue.(type) {
|
||||
case []interface{}:
|
||||
for _, rawInput := range typedInputs {
|
||||
if inputMap, ok := rawInput.(map[string]interface{}); ok {
|
||||
inputs = append(inputs, inputMap)
|
||||
}
|
||||
}
|
||||
case []map[string]interface{}:
|
||||
inputs = append(inputs, typedInputs...)
|
||||
case map[string]interface{}:
|
||||
for name, rawInput := range typedInputs {
|
||||
inputMap := map[string]interface{}{"name": name}
|
||||
if rawInputMap, ok := rawInput.(map[string]interface{}); ok {
|
||||
for key, value := range rawInputMap {
|
||||
inputMap[key] = value
|
||||
}
|
||||
} else {
|
||||
inputMap["default_value"] = rawInput
|
||||
}
|
||||
inputs = append(inputs, inputMap)
|
||||
}
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
func UserInteractionFieldName(field map[string]interface{}) string {
|
||||
if value, ok := field["name"]; ok {
|
||||
if name, ok := value.(string); ok {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func UserInteractionFieldRequired(field map[string]interface{}) bool {
|
||||
if value, ok := field["required"]; ok {
|
||||
return userInteractionBool(value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func userInteractionBool(value interface{}) bool {
|
||||
switch typedValue := value.(type) {
|
||||
case bool:
|
||||
return typedValue
|
||||
case string:
|
||||
return strings.ToLower(typedValue) == "true"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -343,6 +343,12 @@ func listenForEvents() {
|
||||
processEventFinishAndNextStepStart(event)
|
||||
case eventing.TriggerConditionalCheckResponse:
|
||||
processEventFinishAndNextStepStart(event)
|
||||
case eventing.TriggerStepUserInteractionSubmit:
|
||||
if event.ActionSuccess {
|
||||
go resumeWaitingEventStepInstance(event.EventStepInstanceID)
|
||||
} else {
|
||||
processEventFinishAndNextStepStart(event)
|
||||
}
|
||||
case eventing.TriggerCallbackCheckin:
|
||||
go findEventGroupsToStart(event)
|
||||
case eventing.TriggerTagCreate:
|
||||
@@ -694,7 +700,12 @@ func processEventFinishAndNextStepStart(eventNotification EventNotification) {
|
||||
return
|
||||
}
|
||||
foundStepToFinish := false
|
||||
stepOutput := getStepInstanceOutputs(eventNotification, triggeringStep)
|
||||
stepOutput := map[string]interface{}{}
|
||||
if eventNotification.Trigger == eventing.TriggerStepUserInteractionSubmit {
|
||||
stepOutput = triggeringStep.EventStep.Outputs.StructValue()
|
||||
} else {
|
||||
stepOutput = getStepInstanceOutputs(eventNotification, triggeringStep)
|
||||
}
|
||||
// process potential steps for skips
|
||||
inputs := triggeringStep.ActionData.StructValue()
|
||||
skipSteps := []string{}
|
||||
@@ -826,6 +837,12 @@ func startProcessingRunAgainGroupInstanceSteps(oldEventGroupInstanceID int, newO
|
||||
// still need to do something to start processing steps
|
||||
go startProcessingNewEventGroupInstanceSteps(eventgroupinstanceID)
|
||||
}
|
||||
func eventStepStatusBlocksOrder(status string) bool {
|
||||
return status == eventing.EventGroupInstanceStatusQueued ||
|
||||
status == eventing.EventGroupInstanceStatusRunning ||
|
||||
status == eventing.EventGroupInstanceStatusAwaitingApproval ||
|
||||
status == eventing.EventGroupInstanceStatusInputNeeded
|
||||
}
|
||||
func findNextStepToStartAndStartIt(eventSteps []databaseStructs.EventStepInstance, eventGroupInstance databaseStructs.EventGroupInstance) error {
|
||||
currentStepOrder := eventGroupInstance.CurrentOrderStep
|
||||
// marked current step as done, now to see if there are any others to kick off
|
||||
@@ -834,9 +851,7 @@ func findNextStepToStartAndStartIt(eventSteps []databaseStructs.EventStepInstanc
|
||||
for allCurrentStepsAreDone {
|
||||
// make sure all steps of the current order are done before trying to start
|
||||
for i := 0; i < len(eventSteps); i++ {
|
||||
if (eventSteps[i].Status == eventing.EventGroupInstanceStatusRunning ||
|
||||
eventSteps[i].Status == eventing.EventGroupInstanceStatusQueued) &&
|
||||
eventSteps[i].Order == currentStepOrder {
|
||||
if eventStepStatusBlocksOrder(eventSteps[i].Status) && eventSteps[i].Order == currentStepOrder {
|
||||
allCurrentStepsAreDone = false
|
||||
}
|
||||
if finalGroupInstanceStatus == eventing.EventGroupInstanceStatusSuccess && eventSteps[i].Status == eventing.EventGroupInstanceStatusError {
|
||||
@@ -903,8 +918,7 @@ func findNextStepToStartAndStartIt(eventSteps []databaseStructs.EventStepInstanc
|
||||
}
|
||||
// lastly check if any steps are still running / queued
|
||||
for i := 0; i < len(eventSteps); i++ {
|
||||
if eventSteps[i].Status == eventing.EventGroupInstanceStatusQueued ||
|
||||
eventSteps[i].Status == eventing.EventGroupInstanceStatusRunning {
|
||||
if eventStepStatusBlocksOrder(eventSteps[i].Status) {
|
||||
// something is still running or about to run
|
||||
return nil
|
||||
}
|
||||
@@ -919,12 +933,13 @@ func startEventStepInstance(eventStepInstanceID int) error {
|
||||
err := database.DB.Get(&eventStepInstance, `SELECT
|
||||
eventstepinstance.id, eventstepinstance.status, eventstepinstance.order, eventstepinstance.eventgroupinstance_id,
|
||||
eventstepinstance.operator_id, eventstepinstance.operation_id, eventstepinstance.environment,
|
||||
eventstepinstance.continue_on_error,
|
||||
eventstepinstance.continue_on_error, eventstepinstance.user_interaction, eventstepinstance.user_interaction_response,
|
||||
eventstep.action "eventstep.action",
|
||||
eventstep.name "eventstep.name",
|
||||
eventstep.action_data "eventstep.action_data",
|
||||
eventstep.inputs "eventstep.inputs",
|
||||
eventstep.outputs "eventstep.outputs",
|
||||
eventstep.user_interaction "eventstep.user_interaction",
|
||||
eventgroupinstance.environment "eventgroupinstance.environment",
|
||||
eventgroupinstance.eventgroup_id "eventgroupinstance.eventgroup_id"
|
||||
FROM eventstepinstance
|
||||
@@ -947,6 +962,13 @@ func startEventStepInstance(eventStepInstanceID int) error {
|
||||
logging.LogError(err, "failed to get all event step instances")
|
||||
return err
|
||||
}
|
||||
userSuppliedInputs, waitingForUser, err := prepareEventStepUserInteraction(eventStepInstance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if waitingForUser {
|
||||
return nil
|
||||
}
|
||||
// still need to process inputs from outputs of previous steps
|
||||
inputs := eventStepInstance.EventStep.Inputs.StructValue()
|
||||
stepEnv := eventStepInstance.Environment.StructValue()
|
||||
@@ -1074,6 +1096,10 @@ func startEventStepInstance(eventStepInstanceID int) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, val := range userSuppliedInputs {
|
||||
inputs[key] = val
|
||||
actionDataString = replaceVariableInActionDataString(actionDataString, key, val)
|
||||
}
|
||||
eventStepInstance.Inputs = GetMythicJSONTextFromStruct(inputs)
|
||||
// update action data based on event step input as needed
|
||||
actionDataMap := make(map[string]interface{})
|
||||
@@ -1126,6 +1152,64 @@ func replaceVariableInActionDataString(actionDataString string, key string, val
|
||||
}
|
||||
}
|
||||
|
||||
func prepareEventStepUserInteraction(eventStepInstance databaseStructs.EventStepInstance) (map[string]interface{}, bool, error) {
|
||||
config := eventStepInstance.UserInteraction.StructValue()
|
||||
if len(config) == 0 {
|
||||
config = eventStepInstance.EventStep.UserInteraction.StructValue()
|
||||
}
|
||||
if !eventing.UserInteractionHasRequirements(config) {
|
||||
return map[string]interface{}{}, false, nil
|
||||
}
|
||||
response := eventStepInstance.UserInteractionResponse.StructValue()
|
||||
if len(response) > 0 {
|
||||
if approvedInterface, ok := response["approved"]; ok {
|
||||
if approved, ok := approvedInterface.(bool); ok && !approved {
|
||||
return map[string]interface{}{}, false, errors.New("user interaction denied")
|
||||
}
|
||||
}
|
||||
if responseInputs, ok := response["inputs"].(map[string]interface{}); ok {
|
||||
return responseInputs, false, nil
|
||||
}
|
||||
return map[string]interface{}{}, false, nil
|
||||
}
|
||||
nextStatus := eventing.EventGroupInstanceStatusInputNeeded
|
||||
if eventing.UserInteractionApprovalRequired(config) {
|
||||
nextStatus = eventing.EventGroupInstanceStatusAwaitingApproval
|
||||
}
|
||||
eventStepInstance.Status = nextStatus
|
||||
eventStepInstance.UserInteraction = GetMythicJSONTextFromStruct(config)
|
||||
_, err := database.DB.NamedExec(`UPDATE eventstepinstance SET
|
||||
status=:status, user_interaction=:user_interaction
|
||||
WHERE id=:id`, eventStepInstance)
|
||||
if err != nil {
|
||||
logging.LogError(err, "failed to update event step to waiting for user interaction")
|
||||
return map[string]interface{}{}, false, err
|
||||
}
|
||||
_, err = database.DB.Exec(`UPDATE eventgroupinstance SET status=$1 WHERE id=$2 AND end_timestamp IS NULL`,
|
||||
nextStatus, eventStepInstance.EventGroupInstanceID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "failed to update event group to waiting for user interaction")
|
||||
return map[string]interface{}{}, false, err
|
||||
}
|
||||
return map[string]interface{}{}, true, nil
|
||||
}
|
||||
|
||||
func resumeWaitingEventStepInstance(eventStepInstanceID int) {
|
||||
eventStepInstance := databaseStructs.EventStepInstance{}
|
||||
err := database.DB.Get(&eventStepInstance, `SELECT
|
||||
id, eventgroupinstance_id, operation_id, operator_id, continue_on_error
|
||||
FROM eventstepinstance WHERE id=$1`,
|
||||
eventStepInstanceID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "failed to get event step instance for user interaction resume")
|
||||
return
|
||||
}
|
||||
err = startEventStepInstance(eventStepInstanceID)
|
||||
if err != nil {
|
||||
markStepInstanceAsError(eventStepInstance, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// starting a specific step action
|
||||
func convertMapStringToInt(actionDataMap map[string]interface{}, key string, required bool) error {
|
||||
value, exists := actionDataMap[key]
|
||||
@@ -1464,8 +1548,11 @@ func startEventStepInstanceActionInterceptResponse(eventStepInstance databaseStr
|
||||
}
|
||||
func restartFailedJobs(eventgroupInstanceID int) error {
|
||||
_, err := database.DB.Exec(`UPDATE eventstepinstance
|
||||
SET status=$1, end_timestamp=$2 WHERE eventgroupinstance_id=$3 AND status IN ($4, $5)`,
|
||||
eventing.EventGroupInstanceStatusQueued, nil, eventgroupInstanceID,
|
||||
SET status=$1, end_timestamp=$2, user_interaction_response=$3,
|
||||
user_interaction_resolved_by=$4, user_interaction_resolved_at=$5
|
||||
WHERE eventgroupinstance_id=$6 AND status IN ($7, $8)`,
|
||||
eventing.EventGroupInstanceStatusQueued, nil, GetMythicJSONTextFromStruct(map[string]interface{}{}),
|
||||
nil, nil, eventgroupInstanceID,
|
||||
eventing.EventGroupInstanceStatusError, eventing.EventGroupInstanceStatusCancelled)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to update event steps")
|
||||
@@ -1542,11 +1629,13 @@ func restartFromStepJobs(eventStepInstanceID int, retryAllEventGroups bool) erro
|
||||
eventGroupInstances[i].ID == triggerStep.EventGroupInstanceID {
|
||||
|
||||
_, err = database.DB.Exec(`UPDATE eventstepinstance
|
||||
SET status=$1, end_timestamp=$2
|
||||
SET status=$1, end_timestamp=$2, user_interaction_response=$3,
|
||||
user_interaction_resolved_by=$4, user_interaction_resolved_at=$5
|
||||
FROM eventstep
|
||||
WHERE eventstepinstance.eventstep_id = eventstep.id AND
|
||||
eventgroupinstance_id=$3 AND (eventstep.name=$4 OR eventstepinstance.order > $5)`,
|
||||
eventing.EventGroupInstanceStatusQueued, nil, eventGroupInstances[i].ID,
|
||||
eventgroupinstance_id=$6 AND (eventstep.name=$7 OR eventstepinstance.order > $8)`,
|
||||
eventing.EventGroupInstanceStatusQueued, nil, GetMythicJSONTextFromStruct(map[string]interface{}{}),
|
||||
nil, nil, eventGroupInstances[i].ID,
|
||||
triggerStep.EventStep.Name, triggerStep.Order)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to update event steps")
|
||||
@@ -1641,8 +1730,10 @@ func markStepInstanceAsError(eventStepInstance databaseStructs.EventStepInstance
|
||||
func cancelQueuedEventStepInstances(eventGroupInstanceID int) error {
|
||||
_, err := database.DB.Exec(`UPDATE eventstepinstance
|
||||
SET status=$1, end_timestamp=$2
|
||||
WHERE eventgroupinstance_id=$3 AND status=$4`,
|
||||
WHERE eventgroupinstance_id=$3 AND status IN ($4, $5, $6)`,
|
||||
eventing.EventGroupInstanceStatusCancelled, time.Now().UTC(),
|
||||
eventGroupInstanceID, eventing.EventGroupInstanceStatusQueued)
|
||||
eventGroupInstanceID, eventing.EventGroupInstanceStatusQueued,
|
||||
eventing.EventGroupInstanceStatusAwaitingApproval,
|
||||
eventing.EventGroupInstanceStatusInputNeeded)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ func getFormattedEventingFile(eventGroup *databaseStructs.EventGroup, includeSte
|
||||
Environment: step.Environment.StructValue(),
|
||||
Inputs: step.Inputs.StructValue(),
|
||||
Outputs: step.Outputs.StructValue(),
|
||||
UserInteraction: step.UserInteraction.StructValue(),
|
||||
Order: step.Order,
|
||||
ContinueOnError: step.ContinueOnError,
|
||||
})
|
||||
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
package webcontroller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/its-a-feature/Mythic/authentication"
|
||||
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
|
||||
"github.com/its-a-feature/Mythic/eventing"
|
||||
"github.com/its-a-feature/Mythic/logging"
|
||||
"github.com/its-a-feature/Mythic/rabbitmq"
|
||||
|
||||
"github.com/its-a-feature/Mythic/database"
|
||||
)
|
||||
|
||||
type EventingStepUserInteractionSubmitInput struct {
|
||||
Input EventingStepUserInteractionSubmitMessage `json:"input" binding:"required"`
|
||||
}
|
||||
|
||||
type EventingStepUserInteractionSubmitMessage struct {
|
||||
EventStepInstanceID int `json:"eventstepinstance_id" binding:"required"`
|
||||
Approved *bool `json:"approved"`
|
||||
Inputs map[string]interface{} `json:"inputs"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
type EventingStepUserInteractionSubmitMessageResponse struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type eventingStepUserInteractionSubmitRow struct {
|
||||
ID int `db:"id"`
|
||||
Status string `db:"status"`
|
||||
OperationID int `db:"operation_id"`
|
||||
EventGroupInstanceID int `db:"eventgroupinstance_id"`
|
||||
UserInteraction databaseStructs.MythicJSONText `db:"user_interaction"`
|
||||
RunOperatorID int `db:"run_operator_id"`
|
||||
RunOperatorUsername string `db:"run_operator_username"`
|
||||
RunOperatorAccountType string `db:"run_operator_account_type"`
|
||||
}
|
||||
|
||||
func EventingStepUserInteractionSubmitWebhook(c *gin.Context) {
|
||||
var input EventingStepUserInteractionSubmitInput
|
||||
response := EventingStepUserInteractionSubmitMessageResponse{}
|
||||
err := c.ShouldBindJSON(&input)
|
||||
if err != nil {
|
||||
logging.LogError(err, "Failed to get required parameters")
|
||||
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
|
||||
Status: "error",
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
ginOperatorOperation, ok := c.Get(authentication.ContextKeyOperatorOperationStruct)
|
||||
if !ok {
|
||||
logging.LogError(nil, "Failed to get user information")
|
||||
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
|
||||
Status: "error",
|
||||
Error: "Failed to get user information",
|
||||
})
|
||||
return
|
||||
}
|
||||
operatorOperation := ginOperatorOperation.(*databaseStructs.Operatoroperation)
|
||||
interactionRow := eventingStepUserInteractionSubmitRow{}
|
||||
err = database.DB.Get(&interactionRow, `SELECT
|
||||
eventstepinstance.id,
|
||||
eventstepinstance.status,
|
||||
eventstepinstance.operation_id,
|
||||
eventstepinstance.eventgroupinstance_id,
|
||||
eventstepinstance.user_interaction,
|
||||
eventgroupinstance.operator_id "run_operator_id",
|
||||
operator.username "run_operator_username",
|
||||
operator.account_type "run_operator_account_type"
|
||||
FROM eventstepinstance
|
||||
JOIN eventgroupinstance ON eventstepinstance.eventgroupinstance_id = eventgroupinstance.id
|
||||
JOIN operator ON eventgroupinstance.operator_id = operator.id
|
||||
WHERE eventstepinstance.id=$1 AND eventstepinstance.operation_id=$2`,
|
||||
input.Input.EventStepInstanceID, operatorOperation.CurrentOperation.ID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "failed to get event step instance for user interaction")
|
||||
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
|
||||
Status: "error",
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
err = validateEventingStepUserInteractionSubmit(input.Input, interactionRow, operatorOperation)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
|
||||
Status: "error",
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
submittedInputs := normalizeSubmittedUserInteractionInputs(input.Input.Inputs, interactionRow.UserInteraction.StructValue())
|
||||
approved := true
|
||||
if eventing.UserInteractionApprovalRequired(interactionRow.UserInteraction.StructValue()) && input.Input.Approved != nil {
|
||||
approved = *input.Input.Approved
|
||||
}
|
||||
responseData := map[string]interface{}{
|
||||
"approved": approved,
|
||||
"inputs": submittedInputs,
|
||||
"comment": input.Input.Comment,
|
||||
"submitted_by": operatorOperation.CurrentOperator.Username,
|
||||
"submitted_by_id": operatorOperation.CurrentOperator.ID,
|
||||
"submitted_at": time.Now().UTC(),
|
||||
}
|
||||
if !approved {
|
||||
err = resolveEventingStepUserInteraction(interactionRow, responseData, operatorOperation, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
|
||||
Status: "error",
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
rabbitmq.EventingChannel <- rabbitmq.EventNotification{
|
||||
Trigger: eventing.TriggerStepUserInteractionSubmit,
|
||||
OperationID: operatorOperation.CurrentOperation.ID,
|
||||
OperatorID: operatorOperation.CurrentOperator.ID,
|
||||
EventStepInstanceID: input.Input.EventStepInstanceID,
|
||||
ActionSuccess: false,
|
||||
ActionStderr: fmt.Sprintf("User interaction denied by %s", operatorOperation.CurrentOperator.Username),
|
||||
Outputs: map[string]interface{}{},
|
||||
}
|
||||
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{Status: "success"})
|
||||
return
|
||||
}
|
||||
err = resolveEventingStepUserInteraction(interactionRow, responseData, operatorOperation, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
|
||||
Status: "error",
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
rabbitmq.EventingChannel <- rabbitmq.EventNotification{
|
||||
Trigger: eventing.TriggerStepUserInteractionSubmit,
|
||||
OperationID: operatorOperation.CurrentOperation.ID,
|
||||
OperatorID: operatorOperation.CurrentOperator.ID,
|
||||
EventStepInstanceID: input.Input.EventStepInstanceID,
|
||||
ActionSuccess: true,
|
||||
Outputs: map[string]interface{}{},
|
||||
}
|
||||
response.Status = "success"
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func validateEventingStepUserInteractionSubmit(input EventingStepUserInteractionSubmitMessage, interactionRow eventingStepUserInteractionSubmitRow, operatorOperation *databaseStructs.Operatoroperation) error {
|
||||
if interactionRow.Status != eventing.EventGroupInstanceStatusAwaitingApproval &&
|
||||
interactionRow.Status != eventing.EventGroupInstanceStatusInputNeeded {
|
||||
return errors.New("step is not waiting for user interaction")
|
||||
}
|
||||
config := interactionRow.UserInteraction.StructValue()
|
||||
approvalRequired := eventing.UserInteractionApprovalRequired(config)
|
||||
if err := validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, approvalRequired, config); err != nil {
|
||||
return err
|
||||
}
|
||||
if approvalRequired && input.Approved == nil {
|
||||
return errors.New("approval decision is required")
|
||||
}
|
||||
if !approvalRequired && input.Approved != nil && !*input.Approved {
|
||||
return errors.New("approval is not configured for this step")
|
||||
}
|
||||
if input.Approved != nil && !*input.Approved {
|
||||
return nil
|
||||
}
|
||||
if eventing.UserInteractionInputRequired(config) {
|
||||
submittedInputs := normalizeSubmittedUserInteractionInputs(input.Inputs, config)
|
||||
for _, field := range eventing.UserInteractionInputs(config) {
|
||||
if !eventing.UserInteractionFieldRequired(field) {
|
||||
continue
|
||||
}
|
||||
fieldName := eventing.UserInteractionFieldName(field)
|
||||
if fieldName == "" {
|
||||
continue
|
||||
}
|
||||
value, ok := submittedInputs[fieldName]
|
||||
if !ok || isEmptyUserInteractionValue(value) {
|
||||
return fmt.Errorf("missing required input %s", fieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEventingStepUserInteractionResponder(interactionRow eventingStepUserInteractionSubmitRow, operatorOperation *databaseStructs.Operatoroperation, approvalRequired bool, config map[string]interface{}) error {
|
||||
if operatorOperation.ViewMode == database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR {
|
||||
return errors.New("spectators cannot respond to user interaction steps")
|
||||
}
|
||||
if interactionRow.RunOperatorAccountType != databaseStructs.AccountTypeBot {
|
||||
if interactionRow.RunOperatorID != operatorOperation.CurrentOperator.ID {
|
||||
return fmt.Errorf("only %s can provide input for this step", interactionRow.RunOperatorUsername)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !approvalRequired {
|
||||
return nil
|
||||
}
|
||||
switch eventing.UserInteractionBotApprovalApprover(config) {
|
||||
case eventing.UserInteractionApproverLead:
|
||||
if operatorOperation.ViewMode != database.OPERATOR_OPERATION_VIEW_MODE_LEAD {
|
||||
return errors.New("only the operation lead can approve this step")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEmptyUserInteractionValue(value interface{}) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
if valueString, ok := value.(string); ok {
|
||||
return valueString == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeSubmittedUserInteractionInputs(submittedInputs map[string]interface{}, config map[string]interface{}) map[string]interface{} {
|
||||
normalizedInputs := map[string]interface{}{}
|
||||
for key, value := range submittedInputs {
|
||||
normalizedInputs[key] = value
|
||||
}
|
||||
for _, field := range eventing.UserInteractionInputs(config) {
|
||||
fieldName := eventing.UserInteractionFieldName(field)
|
||||
if fieldName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := normalizedInputs[fieldName]; ok {
|
||||
continue
|
||||
}
|
||||
if defaultValue, ok := field["default_value"]; ok {
|
||||
normalizedInputs[fieldName] = defaultValue
|
||||
}
|
||||
}
|
||||
return normalizedInputs
|
||||
}
|
||||
|
||||
func resolveEventingStepUserInteraction(interactionRow eventingStepUserInteractionSubmitRow, responseData map[string]interface{}, operatorOperation *databaseStructs.Operatoroperation, resumeStep bool) error {
|
||||
nextStatus := interactionRow.Status
|
||||
if resumeStep {
|
||||
nextStatus = eventing.EventGroupInstanceStatusQueued
|
||||
}
|
||||
result, err := database.DB.Exec(`UPDATE eventstepinstance SET
|
||||
user_interaction_response=$1,
|
||||
user_interaction_resolved_by=$2,
|
||||
user_interaction_resolved_at=$3,
|
||||
status=$4
|
||||
WHERE id=$5 AND status IN ($6, $7) AND user_interaction_resolved_by IS NULL`,
|
||||
eventing.GetMythicJSONTextFromStruct(responseData),
|
||||
operatorOperation.CurrentOperator.ID,
|
||||
time.Now().UTC(),
|
||||
nextStatus,
|
||||
interactionRow.ID,
|
||||
eventing.EventGroupInstanceStatusAwaitingApproval,
|
||||
eventing.EventGroupInstanceStatusInputNeeded)
|
||||
if err != nil {
|
||||
logging.LogError(err, "failed to resolve event step user interaction")
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
logging.LogError(err, "failed to check event step user interaction update")
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.New("step interaction was already resolved")
|
||||
}
|
||||
if resumeStep {
|
||||
_, err = database.DB.Exec(`UPDATE eventgroupinstance SET status=$1 WHERE id=$2 AND end_timestamp IS NULL`,
|
||||
eventing.EventGroupInstanceStatusRunning, interactionRow.EventGroupInstanceID)
|
||||
if err != nil {
|
||||
logging.LogError(err, "failed to resume event group instance after user interaction")
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package webcontroller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/its-a-feature/Mythic/database"
|
||||
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
|
||||
"github.com/its-a-feature/Mythic/eventing"
|
||||
)
|
||||
|
||||
func TestValidateEventingStepUserInteractionResponderBotApprovalRoles(t *testing.T) {
|
||||
interactionRow := eventingStepUserInteractionSubmitRow{
|
||||
RunOperatorAccountType: databaseStructs.AccountTypeBot,
|
||||
}
|
||||
operatorOperation := &databaseStructs.Operatoroperation{}
|
||||
|
||||
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR
|
||||
err := validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("expected spectator to be blocked from default bot approval")
|
||||
}
|
||||
|
||||
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_OPERATOR
|
||||
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected operator to approve default bot approval: %v", err)
|
||||
}
|
||||
|
||||
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_LEAD
|
||||
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected lead to approve default bot approval: %v", err)
|
||||
}
|
||||
|
||||
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_OPERATOR
|
||||
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{
|
||||
"approval_policy": map[string]interface{}{
|
||||
"bot_context": map[string]interface{}{
|
||||
"approver": eventing.UserInteractionApproverLead,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected operator to be blocked from lead-only bot approval")
|
||||
}
|
||||
|
||||
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_LEAD
|
||||
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{
|
||||
"approval_policy": map[string]interface{}{
|
||||
"bot_context": map[string]interface{}{
|
||||
"approver": eventing.UserInteractionApproverLead,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected lead to approve lead-only bot approval: %v", err)
|
||||
}
|
||||
|
||||
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR
|
||||
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, false, map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("expected spectator to be blocked from bot input-only interaction")
|
||||
}
|
||||
|
||||
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_OPERATOR
|
||||
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, false, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected operator to respond to bot input-only interaction: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventingStepUserInteractionResponderBlocksSpectatorRunOperator(t *testing.T) {
|
||||
interactionRow := eventingStepUserInteractionSubmitRow{
|
||||
RunOperatorAccountType: databaseStructs.AccountTypeUser,
|
||||
RunOperatorID: 7,
|
||||
RunOperatorUsername: "spectator-user",
|
||||
}
|
||||
operatorOperation := &databaseStructs.Operatoroperation{
|
||||
CurrentOperator: databaseStructs.Operator{
|
||||
ID: 7,
|
||||
},
|
||||
ViewMode: database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR,
|
||||
}
|
||||
|
||||
err := validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("expected spectator run operator to be blocked from user interaction")
|
||||
}
|
||||
}
|
||||
@@ -625,6 +625,11 @@ func setRoutes(r *gin.Engine) {
|
||||
mythicjwt.SCOPE_EVENTING_WRITE,
|
||||
}),
|
||||
webcontroller.EventingTriggerRetryFromStepWebhook)
|
||||
noSpectators.POST("eventing_step_user_interaction_submit_webhook",
|
||||
authentication.TokenScopeMiddleware([]string{
|
||||
mythicjwt.SCOPE_EVENTING_WRITE,
|
||||
}),
|
||||
webcontroller.EventingStepUserInteractionSubmitWebhook)
|
||||
noSpectators.POST("eventing_trigger_runagain_webhook",
|
||||
authentication.TokenScopeMiddleware([]string{
|
||||
mythicjwt.SCOPE_EVENTING_WRITE,
|
||||
|
||||
Reference in New Issue
Block a user