make and invalidate bot apitokens

This commit is contained in:
its-a-feature
2026-06-01 09:14:21 -07:00
parent 490c5a9d62
commit c87eb60850
25 changed files with 1039 additions and 1508 deletions
@@ -23,6 +23,7 @@ import {
sanitizeTerminalOutput
} from "./ResponseDisplayInteractive";
import {isAllowedMarkdownLink, markdownPlugins} from "../../utilities/Markdown";
import {markdownComponents} from "../Chat/Chat";
const MaxRenderSize = 2000000;
const RenderModes = {
@@ -47,7 +48,7 @@ const getInitialRenderMode = (props) => {
}
return props?.render_colors ? RenderModes.terminal : RenderModes.plaintext;
}
const markdownComponents = {
const markdownComponentsOld = {
a: ({href, children}) => {
if(!isAllowedMarkdownLink(href)){
return children;
+390 -15
View File
@@ -1,6 +1,7 @@
import React from 'react';
import {gql, useLazyQuery, useMutation, useQuery, useSubscription} from '@apollo/client';
import ReactMarkdown from 'react-markdown';
import Split from 'react-split';
import {alpha, useTheme} from '@mui/material/styles';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
@@ -31,6 +32,8 @@ import CampaignTwoToneIcon from '@mui/icons-material/CampaignTwoTone';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import ForumTwoToneIcon from '@mui/icons-material/ForumTwoTone';
import KeyboardDoubleArrowLeftIcon from '@mui/icons-material/KeyboardDoubleArrowLeft';
import KeyboardDoubleArrowRightIcon from '@mui/icons-material/KeyboardDoubleArrowRight';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
@@ -49,9 +52,12 @@ import {snackActions} from "../../utilities/Snackbar";
import {getSkewedNow} from "../../utilities/Time";
import {isAllowedMarkdownLink, markdownPlugins} from "../../utilities/Markdown";
import {EventStepUserInteractionDialog} from "../Eventing/EventStepRender";
import {SettingsAPITokenDialog} from "../Settings/SettingsOperatorDialog";
const CHAT_MESSAGE_LIMIT = 250;
const CHAT_REQUEST_LIMIT = 50;
const CHAT_SPLIT_STORAGE_KEY = "chatSplitSizes";
const CHAT_SPLIT_DEFAULT_SIZES = [20, 80];
const CHAT_CHANNEL_FIELDS = gql`
fragment ChatChannelFields on chat_channel {
@@ -67,7 +73,18 @@ fragment ChatChannelFields on chat_channel {
chat_container_id
chat_model
ai_metadata
apitokens_id
updated_at
apitoken {
id
name
scopes
token_type
active
deleted
operator_id
created_by
}
chat_container {
id
name
@@ -195,6 +212,14 @@ query ChatCurrentOperator($operator_id: Int!, $operation_id: Int!) {
id
view_mode
}
operation_bot: operator(where: {account_type: {_eq: "bot"}, current_operation_id: {_eq: $operation_id}, active: {_eq: true}, deleted: {_eq: false}}, limit: 1, order_by: {id: asc}) {
id
username
account_type
current_operation_id
active
deleted
}
}
`;
@@ -235,8 +260,8 @@ subscription ChatRequestsStream($channel_id: Int!, $now: timestamp!) {
`;
const CREATE_CHANNEL = gql`
mutation CreateChatChannel($name: String!, $description: String, $channel_type: String, $chat_container_id: Int, $chat_model: String, $locked: Boolean, $ai_metadata: jsonb) {
chatCreateChannel(name: $name, description: $description, channel_type: $channel_type, chat_container_id: $chat_container_id, chat_model: $chat_model, locked: $locked, ai_metadata: $ai_metadata) {
mutation CreateChatChannel($name: String!, $description: String, $channel_type: String, $chat_container_id: Int, $chat_model: String, $locked: Boolean, $ai_metadata: jsonb, $apitokens_id: Int) {
chatCreateChannel(name: $name, description: $description, channel_type: $channel_type, chat_container_id: $chat_container_id, chat_model: $chat_model, locked: $locked, ai_metadata: $ai_metadata, apitokens_id: $apitokens_id) {
status
error
id
@@ -246,8 +271,8 @@ mutation CreateChatChannel($name: String!, $description: String, $channel_type:
`;
const UPDATE_CHANNEL = gql`
mutation UpdateChatChannel($channel_id: Int!, $name: String, $description: String, $archived: Boolean, $locked: Boolean, $chat_model: String, $ai_metadata: jsonb) {
chatUpdateChannel(channel_id: $channel_id, name: $name, description: $description, archived: $archived, locked: $locked, chat_model: $chat_model, ai_metadata: $ai_metadata) {
mutation UpdateChatChannel($channel_id: Int!, $name: String, $description: String, $archived: Boolean, $locked: Boolean, $chat_model: String, $ai_metadata: jsonb, $apitokens_id: Int) {
chatUpdateChannel(channel_id: $channel_id, name: $name, description: $description, archived: $archived, locked: $locked, chat_model: $chat_model, ai_metadata: $ai_metadata, apitokens_id: $apitokens_id) {
status
error
channel_id
@@ -255,6 +280,39 @@ mutation UpdateChatChannel($channel_id: Int!, $name: String, $description: Strin
}
`;
const CHAT_API_TOKENS_QUERY = gql`
query ChatAPITokens($operator_ids: [Int!]!) {
apitokens(where: {operator_id: {_in: $operator_ids}, token_type: {_eq: "api"}, deleted: {_eq: false}, active: {_eq: true}}, order_by: [{operator_id: asc}, {id: desc}]) {
id
name
scopes
token_type
active
deleted
operator_id
created_by
creation_time
}
}
`;
const CREATE_API_TOKEN = gql`
mutation CreateChatAPIToken($operator_id: Int, $name: String, $scopes: [String!]) {
createAPIToken(operator_id: $operator_id, name: $name, scopes: $scopes) {
id
token_value
scopes
token_type
status
error
operator_id
name
created_by
creation_time
}
}
`;
const CREATE_MESSAGE = gql`
mutation CreateChatMessage($channel_id: Int!, $message: String!, $system_message: Boolean = false, $all_operations: Boolean = false) {
chatCreateMessage(channel_id: $channel_id, message: $message, system_message: $system_message, all_operations: $all_operations) {
@@ -353,6 +411,14 @@ query ChatSearch($query: String!, $channel_id: Int, $limit: Int, $offset: Int) {
const isGeneralChatChannel = (channel) => channel?.channel_type === "standard" && channel?.slug === "general";
const getStoredChatSplitSizes = () => {
try {
return JSON.parse(localStorage.getItem(CHAT_SPLIT_STORAGE_KEY));
} catch(error) {
return CHAT_SPLIT_DEFAULT_SIZES;
}
};
const CHAT_SEARCH_SNIPPET_LENGTH = 260;
const CHAT_SEARCH_SNIPPET_CONTEXT = 90;
const timestampHasTimeZone = (timestampText) => /(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(timestampText);
@@ -551,7 +617,7 @@ const MarkdownHeading = ({level, children}) => (
</Typography>
);
const markdownComponents = {
export const markdownComponents = {
p: ({children}) => <Typography component="p" className="mythic-chat-paragraph">{children}</Typography>,
h1: ({children}) => <MarkdownHeading level={1}>{children}</MarkdownHeading>,
h2: ({children}) => <MarkdownHeading level={2}>{children}</MarkdownHeading>,
@@ -892,6 +958,34 @@ const configHasMissingRequiredValues = (values, options) => options.some((option
option.required && `${values[option.name] ?? ""}`.trim() === ""
));
const AI_CHAT_REQUIRED_TOKEN_SCOPES = ["apitoken.write", "chat-ai.write"];
const tokenHasScope = (token, scope) => {
const scopes = token?.scopes || [];
if(scopes.includes("*") || scopes.includes(scope)){
return true;
}
const resource = scope.split(".")[0];
return scopes.includes(`${resource}.*`);
};
const tokenMeetsAIChatRequirements = (token) => AI_CHAT_REQUIRED_TOKEN_SCOPES.every((scope) => tokenHasScope(token, scope));
const formatTokenLabel = (token) => {
if(!token){
return "";
}
const status = token.active && !token.deleted ? "" : " inactive";
return `${token.name || `Token ${token.id}`} (#${token.id})${status}`;
};
const tokenOwnerLabel = (owner) => {
if(!owner){
return "";
}
return owner.accountType === "bot" ? `Operation bot (${owner.username})` : `Current user (${owner.username})`;
};
const applyConfigToMetadata = (metadata, config) => ({
...parseJSONLikeObject(metadata),
config,
@@ -953,6 +1047,192 @@ const ChatConfigurationFields = ({options, values, setValues}) => {
);
};
const ChatAPITokenSelector = ({value, setValue, currentToken, currentUser, operationBot}) => {
const [openCreateToken, setOpenCreateToken] = React.useState(false);
const [localTokens, setLocalTokens] = React.useState([]);
const ownerOptions = React.useMemo(() => {
const owners = [];
if(currentUser?.id){
owners.push({
id: currentUser.id,
username: currentUser.username || `Operator ${currentUser.id}`,
accountType: "user",
});
}
if(operationBot?.id && !owners.some((owner) => owner.id === operationBot.id)){
owners.push({
id: operationBot.id,
username: operationBot.username || `Operator ${operationBot.id}`,
accountType: "bot",
});
}
return owners;
}, [currentUser?.id, currentUser?.username, operationBot?.id, operationBot?.username]);
const ownerIDs = React.useMemo(() => ownerOptions.map((owner) => owner.id), [ownerOptions]);
const ownerIDKey = React.useMemo(() => ownerIDs.join(","), [ownerIDs]);
const [tokenOwnerID, setTokenOwnerID] = React.useState("");
const {data, loading} = useQuery(CHAT_API_TOKENS_QUERY, {
variables: {operator_ids: ownerIDs.length > 0 ? ownerIDs : [0]},
skip: ownerIDs.length === 0,
fetchPolicy: "no-cache",
});
React.useEffect(() => {
setLocalTokens([]);
}, [ownerIDKey]);
React.useEffect(() => {
if(data?.apitokens){
setLocalTokens(data.apitokens);
}
}, [data]);
React.useEffect(() => {
if(currentToken?.operator_id && ownerOptions.some((owner) => owner.id === currentToken.operator_id)){
setTokenOwnerID(currentToken.operator_id);
return;
}
setTokenOwnerID((previousOwnerID) => (
ownerOptions.length > 0 && !ownerOptions.some((owner) => `${owner.id}` === `${previousOwnerID}`) ?
ownerOptions[0].id :
previousOwnerID
));
}, [currentToken?.id, currentToken?.operator_id, ownerOptions]);
const selectedOwner = ownerOptions.find((owner) => `${owner.id}` === `${tokenOwnerID}`) || null;
const tokens = React.useMemo(() => {
const combined = localTokens.filter((token) => `${token.operator_id}` === `${tokenOwnerID}`);
if(currentToken?.id && `${currentToken.operator_id}` === `${tokenOwnerID}` && !combined.some((token) => token.id === currentToken.id)){
combined.unshift(currentToken);
}
if(value && !combined.some((token) => `${token.id}` === `${value}`)){
combined.unshift({id: value, name: `Token ${value}`, scopes: [], active: true, deleted: false, operator_id: tokenOwnerID});
}
return combined;
}, [localTokens, currentToken, value, tokenOwnerID]);
const selectedToken = tokens.find((token) => `${token.id}` === `${value}`) || null;
const createTokenInitialScopes = React.useMemo(() => selectedToken?.scopes || [], [selectedToken]);
const [createAPIToken] = useMutation(CREATE_API_TOKEN, {
onCompleted: (result) => {
if(result.createAPIToken.status === "success"){
const {token_value, ...createdToken} = result.createAPIToken;
setLocalTokens((prev) => [{...createdToken, active: true, deleted: false}, ...prev]);
setTokenOwnerID(createdToken.operator_id);
setValue(createdToken.id);
snackActions.success("Created and selected API token");
setOpenCreateToken(false);
}else{
snackActions.error(result.createAPIToken.error);
}
},
onError: (error) => snackActions.error(error.message),
});
const createToken = (name, scopes) => {
createAPIToken({variables: {operator_id: selectedOwner?.id, name, scopes}});
};
const changeTokenOwner = (event) => {
setTokenOwnerID(Number(event.target.value));
setValue("");
};
const tokenSelectorDisabled = !selectedOwner;
return (
<Box sx={{display: "flex", flexDirection: "column", gap: 1}}>
<Box sx={{border: `1px solid ${alpha("#ffffff", 0.12)}`, borderRadius: 1, p: 1.25}}>
<Typography variant="subtitle2">AI Chat API Token</Typography>
<Typography variant="caption" color="text.secondary" sx={{display: "block", mt: 0.5}}>
AI chat can use a token for your operator account or the operation bot{operationBot?.username ? ` (${operationBot.username})` : ""}. The token must include apitoken.write to generate scoped Mythic API tokens and chat-ai.write to stream responses back to this channel.
</Typography>
<Box sx={{display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1}}>
{AI_CHAT_REQUIRED_TOKEN_SCOPES.map((scope) => (
<Chip key={scope} size="small" label={scope} color={selectedToken && tokenHasScope(selectedToken, scope) ? "success" : "warning"} />
))}
</Box>
</Box>
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", md: "minmax(160px, 0.55fr) minmax(220px, 1fr) auto"}, gap: 1, alignItems: "stretch"}}>
<FormControl size="small" fullWidth required>
<InputLabel>Owner</InputLabel>
<Select
label="Owner"
value={tokenOwnerID ? `${tokenOwnerID}` : ""}
disabled={ownerOptions.length === 0}
onChange={changeTokenOwner}
>
{ownerOptions.length === 0 &&
<MenuItem value="" disabled>No token owners available</MenuItem>
}
{ownerOptions.map((owner) => (
<MenuItem value={`${owner.id}`} key={`chat-token-owner-${owner.id}`}>
{tokenOwnerLabel(owner)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" fullWidth required>
<InputLabel>API Token</InputLabel>
<Select
label="API Token"
value={value ? `${value}` : ""}
disabled={tokenSelectorDisabled}
onChange={(event) => setValue(Number(event.target.value))}
>
{!selectedOwner &&
<MenuItem value="" disabled>Select a token owner first</MenuItem>
}
{tokens.length === 0 &&
<MenuItem value="" disabled>{loading ? "Loading API tokens..." : "No active API tokens available"}</MenuItem>
}
{tokens.map((token) => (
<MenuItem value={`${token.id}`} key={`chat-token-${token.id}`}>
<Box sx={{display: "flex", flexDirection: "column", py: 0.25}}>
<Typography variant="body2">{formatTokenLabel(token)}</Typography>
<Typography variant="caption" color={tokenMeetsAIChatRequirements(token) ? "text.secondary" : "warning.main"} sx={{whiteSpace: "normal"}}>
{(token.scopes || []).join(", ") || "No scopes"}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="outlined"
size="small"
disabled={tokenSelectorDisabled}
onClick={() => setOpenCreateToken(true)}
sx={{height: 40, whiteSpace: "nowrap", px: 2}}
>
Create
</Button>
</Box>
{selectedToken &&
<Box sx={{display: "flex", flexWrap: "wrap", gap: 0.5}}>
{selectedOwner &&
<Chip size="small" variant="outlined" label={tokenOwnerLabel(selectedOwner)} />
}
{(selectedToken.scopes || []).map((scope) => <Chip size="small" key={`${selectedToken.id}-${scope}`} label={scope} />)}
</Box>
}
{openCreateToken &&
<MythicDialog
open={openCreateToken}
fullWidth={true}
maxWidth="md"
onClose={() => setOpenCreateToken(false)}
innerDialog={
<SettingsAPITokenDialog
title={`New AI Chat API Token${selectedOwner ? ` for ${selectedOwner.username}` : ""}`}
name={`${selectedOwner?.accountType === "bot" ? "Operation bot" : "Operator"} AI chat token`}
initialScopes={createTokenInitialScopes}
requiredScopes={AI_CHAT_REQUIRED_TOKEN_SCOPES}
requiredScopeDescriptions={{
"apitoken.write": "Required so the chat container can request scoped Mythic API tokens for tools.",
"chat-ai.write": "Required so the chat container can stream AI responses back into this channel.",
}}
onAccept={createToken}
handleClose={() => setOpenCreateToken(false)}
/>
}
/>
}
</Box>
);
};
const ChatEmptyState = ({icon, title, detail}) => (
<Box className="mythic-chat-empty-state">
{icon}
@@ -1108,7 +1388,7 @@ const MessageBubble = ({message, request, me, onEdit, onDelete, onCancel, onRetr
);
};
const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
const ChatCreateDialog = ({open, onClose, onCreate, chatContainers, currentUser, operationBot}) => {
const theme = useTheme();
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
@@ -1117,6 +1397,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
const [model, setModel] = React.useState("");
const [configValues, setConfigValues] = React.useState({});
const [locked, setLocked] = React.useState(true);
const [apiTokenID, setAPITokenID] = React.useState("");
React.useEffect(() => {
if(open){
setName("");
@@ -1126,6 +1407,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
setModel("");
setConfigValues({});
setLocked(true);
setAPITokenID("");
}
}, [open]);
const selectedContainer = React.useMemo(() => (
@@ -1164,6 +1446,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
setContainerID("");
setModel("");
setConfigValues({});
setAPITokenID("");
}
};
const changeContainer = (event) => {
@@ -1175,7 +1458,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
setConfigValues({});
};
const createDisabled = name.trim() === "" ||
(channelType === "ai" && (!containerID || selectedContainerModels.length === 0 || model === "" || configHasMissingRequiredValues(configValues, configOptions)));
(channelType === "ai" && (!containerID || selectedContainerModels.length === 0 || model === "" || !apiTokenID || configHasMissingRequiredValues(configValues, configOptions)));
const submit = () => {
const aiConfig = channelType === "ai" ? normalizeConfigForSubmit(configValues, configOptions) : {};
onCreate({
@@ -1186,6 +1469,7 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
chat_model: channelType === "ai" ? model : "",
locked: channelType === "ai" ? locked : false,
ai_metadata: channelType === "ai" ? applyConfigToMetadata({}, aiConfig) : {},
apitokens_id: channelType === "ai" ? Number(apiTokenID) : null,
});
};
return (
@@ -1254,6 +1538,12 @@ const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => {
{configOptions.length > 0 &&
<ChatConfigurationFields options={configOptions} values={configValues} setValues={setConfigValues} />
}
<ChatAPITokenSelector
value={apiTokenID}
setValue={setAPITokenID}
currentUser={currentUser}
operationBot={operationBot}
/>
<Box sx={{border: `1px solid ${alpha(theme.palette.info.main, 0.22)}`, borderRadius: 1, p: 1.25, backgroundColor: alpha(theme.palette.info.main, theme.palette.mode === "dark" ? 0.12 : 0.07)}}>
<FormControlLabel
control={<Switch checked={locked} onChange={(e) => setLocked(e.target.checked)} />}
@@ -1331,11 +1621,12 @@ const ChatSearchDialog = ({open, onClose, onSearch, searchText, setSearchText, s
);
};
const ChatEditChannelDialog = ({open, channel, onClose, onSave, chatContainers = []}) => {
const ChatEditChannelDialog = ({open, channel, onClose, onSave, chatContainers = [], currentUser, operationBot}) => {
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [chatModel, setChatModel] = React.useState("");
const [configValues, setConfigValues] = React.useState({});
const [apiTokenID, setAPITokenID] = React.useState("");
const isGeneralChannel = isGeneralChatChannel(channel);
const isAIChannel = channel?.channel_type === "ai";
const containerModels = React.useMemo(() => {
@@ -1354,6 +1645,7 @@ const ChatEditChannelDialog = ({open, channel, onClose, onSave, chatContainers =
setName(channel.name || "");
setDescription(channel.description || "");
setChatModel(channel.chat_model || "");
setAPITokenID(channel.apitokens_id || "");
const initialModel = modelForChannel(channel, chatContainers);
setConfigValues(buildDefaultConfigValues(getModelConfigOptions(initialModel), getChannelAIConfig(channel)));
}
@@ -1382,11 +1674,12 @@ const ChatEditChannelDialog = ({open, channel, onClose, onSave, chatContainers =
getChannelAIMetadata(channel),
normalizeConfigForSubmit(configValues, configOptions),
);
update.apitokens_id = Number(apiTokenID);
}
onSave(update);
};
const saveDisabled = (!isGeneralChannel && name.trim() === "") ||
(isAIChannel && configHasMissingRequiredValues(configValues, configOptions));
(isAIChannel && (!apiTokenID || configHasMissingRequiredValues(configValues, configOptions)));
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{isAIChannel ? "Edit AI Chat" : "Edit Channel"}</DialogTitle>
@@ -1438,6 +1731,13 @@ const ChatEditChannelDialog = ({open, channel, onClose, onSave, chatContainers =
</Select>
</FormControl>
<ChatConfigurationFields options={configOptions} values={configValues} setValues={setConfigValues} />
<ChatAPITokenSelector
value={apiTokenID}
setValue={setAPITokenID}
currentToken={channel?.apitoken}
currentUser={currentUser}
operationBot={operationBot}
/>
</>
}
</DialogContent>
@@ -1529,6 +1829,7 @@ export function Chat({me}) {
const [editText, setEditText] = React.useState("");
const [reviewMessage, setReviewMessage] = React.useState(null);
const [refreshingSpecialMessageID, setRefreshingSpecialMessageID] = React.useState(null);
const [chatSplitSizes, setChatSplitSizes] = React.useState(getStoredChatSplitSizes);
const messagesContainerRef = React.useRef(null);
const messagesEndRef = React.useRef(null);
const messagesNearBottomRef = React.useRef(true);
@@ -1540,6 +1841,10 @@ export function Chat({me}) {
lastMessageStatus: null,
lastMessageLength: 0,
});
const readStateRef = React.useRef({});
const submittedReadStateRef = React.useRef({});
const pendingReadStateRef = React.useRef({});
const markReadTimersRef = React.useRef({});
const streamStart = React.useRef(getSkewedNow().toISOString());
const [baseChannels, setBaseChannels] = React.useState([]);
const [allChatContainers, setAllChatContainers] = React.useState([]);
@@ -1549,6 +1854,7 @@ export function Chat({me}) {
const selectedChannelIDRef = React.useRef(selectedChannelID);
const selectedChannelStreamStart = React.useMemo(() => getSkewedNow().toISOString(), [selectedChannelID]);
selectedChannelIDRef.current = selectedChannelID;
readStateRef.current = readState;
const {data: channelData} = useQuery(CHAT_CHANNELS_QUERY, {fetchPolicy: "no-cache"});
const {data: readStateData} = useQuery(CHAT_READ_STATE_QUERY, {fetchPolicy: "no-cache"});
@@ -1635,10 +1941,19 @@ export function Chat({me}) {
const generalChannel = React.useMemo(() => {
return channels.find((channel) => isGeneralChatChannel(channel)) || null;
}, [channels]);
const currentUser = React.useMemo(() => ({
id: currentOperatorData?.operator_by_pk?.id || currentMe?.user?.user_id || 0,
username: currentOperatorData?.operator_by_pk?.username || currentMe?.user?.username || "Current user",
}), [currentOperatorData?.operator_by_pk?.id, currentOperatorData?.operator_by_pk?.username, currentMe?.user?.user_id, currentMe?.user?.username]);
const currentOperatorViewMode = currentOperatorData?.operatoroperation?.[0]?.view_mode || currentMe?.user?.view_mode || "";
const operationBot = currentOperatorData?.operation_bot?.[0] || null;
const isMythicAdmin = Boolean(currentMe?.user?.admin || currentOperatorData?.operator_by_pk?.admin);
const canCreateSystemMessage = isMythicAdmin || currentOperatorViewMode === "lead";
const selectedChannelIsGeneral = isGeneralChatChannel(selectedChannel);
const updateChatSplitSizes = React.useCallback((sizes) => {
setChatSplitSizes(sizes);
localStorage.setItem(CHAT_SPLIT_STORAGE_KEY, JSON.stringify(sizes));
}, []);
React.useEffect(() => {
if(!selectedChannelID && channels.length > 0){
@@ -1772,6 +2087,57 @@ export function Chat({me}) {
onError: (error) => snackActions.error(error.message),
});
const [markRead] = useMutation(MARK_READ);
const queueMarkRead = React.useCallback((channelID, messageID) => {
if(!channelID || !messageID){
return;
}
const currentReadID = readStateRef.current[channelID] || 0;
const submittedReadID = submittedReadStateRef.current[channelID] || 0;
const pendingReadID = pendingReadStateRef.current[channelID] || 0;
if(messageID <= Math.max(currentReadID, submittedReadID, pendingReadID)){
return;
}
pendingReadStateRef.current[channelID] = messageID;
if(markReadTimersRef.current[channelID]){
clearTimeout(markReadTimersRef.current[channelID]);
}
markReadTimersRef.current[channelID] = setTimeout(() => {
const lastReadMessageID = pendingReadStateRef.current[channelID] || 0;
delete pendingReadStateRef.current[channelID];
delete markReadTimersRef.current[channelID];
const latestKnownReadID = Math.max(
readStateRef.current[channelID] || 0,
submittedReadStateRef.current[channelID] || 0
);
if(lastReadMessageID <= latestKnownReadID){
return;
}
submittedReadStateRef.current[channelID] = lastReadMessageID;
markRead({variables: {channel_id: channelID, last_read_message_id: lastReadMessageID}}).catch(() => {
if((submittedReadStateRef.current[channelID] || 0) <= lastReadMessageID){
submittedReadStateRef.current[channelID] = readStateRef.current[channelID] || 0;
}
});
}, 750);
}, [markRead]);
React.useEffect(() => {
Object.entries(readState).forEach(([channelID, lastReadMessageID]) => {
submittedReadStateRef.current[channelID] = Math.max(submittedReadStateRef.current[channelID] || 0, lastReadMessageID || 0);
if((pendingReadStateRef.current[channelID] || 0) <= (lastReadMessageID || 0)){
delete pendingReadStateRef.current[channelID];
if(markReadTimersRef.current[channelID]){
clearTimeout(markReadTimersRef.current[channelID]);
delete markReadTimersRef.current[channelID];
}
}
});
}, [readState]);
React.useEffect(() => {
return () => {
Object.values(markReadTimersRef.current).forEach((timer) => clearTimeout(timer));
markReadTimersRef.current = {};
};
}, []);
const [refreshSpecialMessage] = useMutation(REFRESH_SPECIAL_MESSAGE, {
onCompleted: (data) => {
if(data.chatRefreshSpecialMessage.status !== "success"){
@@ -1838,9 +2204,9 @@ export function Chat({me}) {
}
if(selectedChannelID && lastMessageMatchesChannel){
markRead({variables: {channel_id: selectedChannelID, last_read_message_id: lastMessage.id}}).catch(() => {});
queueMarkRead(selectedChannelID, lastMessage.id);
}
}, [messages, selectedChannelID, markRead]);
}, [messages, selectedChannelID, queueMarkRead]);
const standardChannels = channels.filter((channel) => channel.channel_type === "standard" && (showArchived || !channel.archived));
const aiChannels = channels.filter((channel) => channel.channel_type === "ai" && (showArchived || !channel.archived));
@@ -1983,11 +2349,16 @@ export function Chat({me}) {
</Box>
}
/>
<Box
className="mythic-chat-layout"
sx={{
<Split
className={`mythic-chat-layout`}
direction="horizontal"
sizes={chatSplitSizes}
minSize={[0, 0]}
onDragEnd={updateChatSplitSizes}
style={{
backgroundColor: "transparent",
boxShadow: "none",
width: "100%",
}}
>
<Box className="mythic-chat-sidebar">
@@ -2175,12 +2546,14 @@ export function Chat({me}) {
</IconButton>
</Box>
</Box>
</Box>
</Split>
<ChatCreateDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreate={onCreateChannel}
chatContainers={chatContainers}
currentUser={currentUser}
operationBot={operationBot}
/>
<ChatEditChannelDialog
open={editChannelOpen}
@@ -2188,6 +2561,8 @@ export function Chat({me}) {
chatContainers={chatContainers}
onClose={() => setEditChannelOpen(false)}
onSave={saveChannelDetails}
currentUser={currentUser}
operationBot={operationBot}
/>
<ChatSystemMessageDialog
open={systemMessageOpen}
@@ -37,6 +37,26 @@ query apiTokenScopeDefinitionsQuery {
}
`;
const normalizeAPITokenScopeSelection = (scopes) => {
const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope)))).sort();
if(uniqueScopes.includes("*")){
return ["*"];
}
return uniqueScopes.reduce((prev, scope) => {
const resource = scope.endsWith(".*") ? scope.slice(0, -2) : scope.split(".")[0];
if(scope.endsWith(".*")){
return [
...prev.filter((selectedScope) => !selectedScope.startsWith(`${resource}.`)),
scope,
].sort();
}
if(prev.includes(`${resource}.*`)){
return prev;
}
return [...prev, scope].sort();
}, []);
};
export function SettingsOperatorDialog(props) {
const [username, setUsername] = React.useState(props.username ? props.username : "");
@@ -208,7 +228,13 @@ export function SettingsBotDialog(props) {
}
export function SettingsAPITokenDialog(props) {
const [name, setName] = React.useState(props.name ? props.name : "");
const [selectedScopes, setSelectedScopes] = React.useState([]);
const requiredScopes = React.useMemo(() => props.requiredScopes || [], [props.requiredScopes]);
const requiredScopeDescriptions = props.requiredScopeDescriptions || {};
const initialScopes = React.useMemo(() => props.initialScopes || [], [props.initialScopes]);
const initialSelectedScopes = React.useMemo(() => (
normalizeAPITokenScopeSelection([...initialScopes, ...requiredScopes])
), [initialScopes, requiredScopes]);
const [selectedScopes, setSelectedScopes] = React.useState(initialSelectedScopes);
const [filter, setFilter] = React.useState("");
const {data: scopeData, loading: scopeLoading, error: scopeQueryError} = useQuery(apiTokenScopeDefinitionsQuery, {
fetchPolicy: "no-cache",
@@ -254,6 +280,11 @@ export function SettingsAPITokenDialog(props) {
const scopesUnavailable = scopeLoading || scopeLoadFailed;
const selectedScopesLabel = selectedScopes.includes("*") ? "Full access selected" : `${selectedScopes.length} selected`;
const fullAccessDisabled = !grantableWildcards.includes("*");
React.useEffect(() => {
if(initialSelectedScopes.length > 0){
setSelectedScopes((prev) => Array.from(new Set([...initialSelectedScopes, ...prev])).sort());
}
}, [initialSelectedScopes]);
const onUsernameChange = (name, value, error) => {
setName(value);
@@ -344,6 +375,18 @@ export function SettingsAPITokenDialog(props) {
</>
}
>
{requiredScopes.length > 0 &&
<Box className="mythic-api-token-scope-card mythic-api-token-scope-card-selected" sx={{mb: 1.25}}>
<Box className="mythic-api-token-scope-card-copy">
<Typography className="mythic-api-token-scope-card-title">Needed for this use</Typography>
{requiredScopes.map((scope) => (
<Typography key={`required-${scope}`} variant="caption" color="text.secondary" sx={{display: "block"}}>
{scope}: {requiredScopeDescriptions[scope] || "Required for this workflow."}
</Typography>
))}
</Box>
</Box>
}
<TextField
className="mythic-api-token-scope-search"
size="small"
+25 -7
View File
@@ -11550,24 +11550,45 @@ tspan {
font-size: 1rem;
}
.mythic-chat-layout {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 8px;
display: flex;
min-height: 0;
flex: 1 1 auto;
border-radius: 8px;
overflow: visible;
}
.mythic-chat-layout > .gutter.gutter-horizontal {
cursor: col-resize;
margin: 0;
position: relative;
margin-top: 10px;
margin-bottom: 10px;
}
.mythic-chat-layout > .gutter.gutter-horizontal::after {
background-color: var(--mythic-global-411);
border-radius: 999px;
content: "";
position: absolute;
transition: background-color 120ms ease;
}
.mythic-chat-layout > .gutter.gutter-horizontal:hover::after {
background-color: var(--mythic-global-070);
}
.mythic-chat-sidebar {
background-color: var(--mythic-global-410);
border: 1px solid var(--mythic-global-411);
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
border-radius: 8px;
box-shadow: inset 0 1px 0 var(--mythic-global-096);
overflow: hidden;
}
.mythic-chat-layout-channels-collapsed .mythic-chat-sidebar {
border-left-width: 0;
border-right-width: 0;
opacity: 0;
pointer-events: none;
}
.mythic-chat-sidebar-toolbar {
background-color: var(--mythic-global-412);
border-bottom: 1px solid var(--mythic-global-411);
@@ -12257,9 +12278,6 @@ tspan {
text-align: center;
}
@media (max-width: 900px) {
.mythic-chat-layout {
grid-template-columns: 1fr;
}
.mythic-chat-sidebar {
max-height: 220px;
}
+8 -6
View File
@@ -19,10 +19,12 @@ func TestMythicConnection() {
webAddress := "127.0.0.1"
mythicEnv := config.GetMythicEnv()
queryBodyString := `
query meHook {
status
error
}
query whoami {
whoami {
status
error
}
}
`
if mythicEnv.GetString("NGINX_HOST") == "mythic_nginx" {
@@ -54,9 +56,9 @@ func TestMythicConnection() {
resp.Body.Close()
if resp.StatusCode == 200 || resp.StatusCode == 403 || resp.StatusCode == 404 {
if resp.StatusCode == 403 {
log.Printf("[+] Successfully connected to Mythic at " + loginAddress + ", but blocked by ALLOWED_IP_BLOCKS setting\n\n")
log.Printf("[+] Successfully connected to Mythic at %s, but blocked by ALLOWED_IP_BLOCKS setting\n\n", loginAddress)
} else {
log.Printf("[+] Successfully connected to Mythic at " + loginAddress + "\n\n")
log.Printf("[+] Successfully connected to Mythic at %s\n\n", loginAddress)
}
for j := range count {
log.Printf("[*] Attempting to connect to Mythic's GraphQL, attempt %d/%d\n", j+1, maxCount)
+22 -2
View File
@@ -66,6 +66,7 @@ type Mutation {
chat_model: String
locked: Boolean
ai_metadata: jsonb
apitokens_id: Int
): ChatActionOutput
}
@@ -128,6 +129,7 @@ type Mutation {
locked: Boolean
chat_model: String
ai_metadata: jsonb
apitokens_id: Int
): ChatActionOutput
}
@@ -526,7 +528,7 @@ type Mutation {
}
type Query {
meHook: meHookOutput
whoami: whoamiOutput
}
type Mutation {
@@ -1026,11 +1028,29 @@ type previewFileOutput {
contents: String
}
type meHookOutput {
type whoamiOutput {
status: String!
error: String
user_id: Int
username: String
email: String
account_type: String
admin: Boolean
active: Boolean
deleted: Boolean
current_operation_id: Int
current_operation: String
view_mode: String
view_utc_time: Boolean
last_login: String
current_utc_time: String
auth_method: String
apitoken: jsonb
eventstepinstance_id: Int
scopes: [String!]
effective_scopes: [String!]
operations: jsonb
scope_info: jsonb
}
type deleteTasksAndCallbacksOutput {
+3 -3
View File
@@ -765,10 +765,10 @@ actions:
- role: operation_admin
- role: mythic_admin
- role: developer
- name: meHook
- name: whoami
definition:
kind: ""
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/me_webhook'
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/whoami_webhook'
forward_client_headers: true
permissions:
- role: spectator
@@ -1088,7 +1088,7 @@ custom_types:
type: object
- name: generateReportOutput
- name: previewFileOutput
- name: meHookOutput
- name: whoamiOutput
- name: deleteTasksAndCallbacksOutput
- name: sendExternalWebhookOutput
- name: deleteTagtypeOutput
@@ -5,6 +5,9 @@ object_relationships:
- name: callback
using:
foreign_key_constraint_on: callback_id
- name: chat_channel
using:
foreign_key_constraint_on: chat_channel_id
- name: created_by_operator
using:
foreign_key_constraint_on: created_by
@@ -21,6 +24,13 @@ object_relationships:
using:
foreign_key_constraint_on: task_id
array_relationships:
- name: chat_channels
using:
foreign_key_constraint_on:
column: apitokens_id
table:
name: chat_channel
schema: public
- name: callbackgraphedges
using:
foreign_key_constraint_on:
@@ -140,6 +150,7 @@ select_permissions:
- active
- deleted
- callback_id
- chat_channel_id
- created_by
- eventstepinstance_id
- id
@@ -169,6 +180,7 @@ select_permissions:
- active
- deleted
- callback_id
- chat_channel_id
- created_by
- eventstepinstance_id
- id
@@ -187,6 +199,12 @@ select_permissions:
_eq: X-Hasura-User-Id
- operator_id:
_eq: X-Hasura-User-Id
- operator:
_and:
- account_type:
_eq: bot
- current_operation_id:
_eq: X-Hasura-current-operation-id
- operator_id:
_lte: X-Hasura-Scope-apitoken-read-operator
- role: operator
@@ -195,6 +213,7 @@ select_permissions:
- active
- deleted
- callback_id
- chat_channel_id
- created_by
- eventstepinstance_id
- id
@@ -213,6 +232,12 @@ select_permissions:
_eq: X-Hasura-User-Id
- operator_id:
_eq: X-Hasura-User-Id
- operator:
_and:
- account_type:
_eq: bot
- current_operation_id:
_eq: X-Hasura-current-operation-id
- operator_id:
_lte: X-Hasura-Scope-apitoken-read-operator
- role: spectator
@@ -221,6 +246,7 @@ select_permissions:
- active
- deleted
- callback_id
- chat_channel_id
- created_by
- eventstepinstance_id
- id
@@ -5,6 +5,9 @@ object_relationships:
- name: archived_operator
using:
foreign_key_constraint_on: archived_by
- name: apitoken
using:
foreign_key_constraint_on: apitokens_id
- name: chat_container
using:
foreign_key_constraint_on: chat_container_id
@@ -44,6 +47,7 @@ select_permissions:
permission:
columns:
- ai_metadata
- apitokens_id
- archived
- archived_at
- archived_by
@@ -82,6 +86,7 @@ select_permissions:
permission:
columns:
- ai_metadata
- apitokens_id
- archived
- archived_at
- archived_by
@@ -120,6 +125,7 @@ select_permissions:
permission:
columns:
- ai_metadata
- apitokens_id
- archived
- archived_at
- archived_by
@@ -158,6 +164,7 @@ select_permissions:
permission:
columns:
- ai_metadata
- apitokens_id
- archived
- archived_at
- archived_by
+5 -1
View File
@@ -126,6 +126,7 @@ func GetClaims(c *gin.Context) (*mythicjwt.CustomClaims, error) {
}
tokenString, err := ExtractToken(c)
if err != nil {
logging.LogError(err, "failed to extract token")
return nil, err
}
if looksLikeOpaqueAPIToken(tokenString) {
@@ -139,6 +140,7 @@ func GetClaims(c *gin.Context) (*mythicjwt.CustomClaims, error) {
return typedClaims, nil
}
}
logging.LogError(errors.New("failed to get claims from validated apitoken"), "failed to get claims from validated apitoken")
return nil, errors.New("failed to get claims from validated apitoken")
}
claims := mythicjwt.CustomClaims{}
@@ -166,8 +168,10 @@ func TokenValid(c *gin.Context) error {
operator, err := database.GetUserFromID(claims.UserID)
if err == nil {
c.Set(ContextKeyUsername, operator.Username)
} else {
logging.LogError(err, "failed to get user from id")
}
return nil
return err
}
type HasuraRequest struct {
@@ -37,6 +37,7 @@ var (
AUTH_METHOD_ON_START = "on_start"
AUTH_METHOD_PAYLOAD = "payload"
AUTH_METHOD_CALLBACK = "callback"
AUTH_METHOD_CHAT = "chat"
ErrFailedToFindRefreshToken = errors.New("Failed to find refresh token for specified access token")
ErrRefreshTokenMissmatch = errors.New("Refresh token doesn't match for the given access token")
ErrUnexpectedSigningMethod = errors.New("Unexpected signing method")
@@ -137,7 +137,13 @@ create table if not exists "public"."chat_message" (
message text not null default '',
deleted boolean not null default false,
search_vector tsvector generated always as (
to_tsvector('simple', case when deleted then '' else coalesce(message, '') end)
to_tsvector(
'simple',
case
when deleted or status <> 'complete' then ''
else coalesce(message, '')
end
)
) stored,
edited boolean not null default false,
edited_at timestamp without time zone,
@@ -228,6 +234,10 @@ create table if not exists "public"."chat_request" (
create index if not exists chat_request_operation_channel_idx
on "public"."chat_request" using btree (operation_id, channel_id, id desc);
create index if not exists chat_request_active_operation_channel_idx
on "public"."chat_request" using btree (operation_id, channel_id, id desc)
where status in ('pending', 'streaming');
create index if not exists chat_request_response_message_id_idx
on "public"."chat_request" using btree (response_message_id);
@@ -447,6 +457,7 @@ drop index if exists "public"."mythictree_operation_tree_host_full_callback_idx"
drop index if exists "public"."mythictree_operation_tree_host_parent_callback_idx";
drop index if exists "public"."operationeventlog_unresolved_warning_source_operation_level_idx";
drop index if exists "public"."chat_channel_operation_unread_idx";
drop index if exists "public"."chat_request_active_operation_channel_idx";
drop trigger if exists update_chat_channel_last_message_id_trigger on "public"."chat_message";
drop function if exists public.update_chat_channel_last_message_id();
drop trigger if exists create_default_chat_channel_for_operation_trigger on "public"."operation";
@@ -15,9 +15,46 @@ alter table "public"."eventstepinstance" add constraint "eventstepinstance_user_
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";
alter table "public"."chat_channel"
add column if not exists "apitokens_id" integer;
alter table "public"."chat_channel" drop constraint if exists "chat_channel_apitokens_id_fkey";
alter table "public"."chat_channel" add constraint "chat_channel_apitokens_id_fkey"
foreign key ("apitokens_id") references "public"."apitokens"("id") on update restrict on delete restrict not valid;
alter table "public"."chat_channel" validate constraint "chat_channel_apitokens_id_fkey";
alter table "public"."chat_channel" drop constraint if exists "chat_channel_ai_apitoken_check";
alter table "public"."chat_channel" add constraint "chat_channel_ai_apitoken_check"
check (channel_type <> 'ai' or apitokens_id is not null) not valid;
create index if not exists chat_channel_apitokens_id_idx
on "public"."chat_channel" using btree (apitokens_id);
alter table "public"."apitokens"
add column if not exists "chat_channel_id" integer;
alter table "public"."apitokens" drop constraint if exists "apitokens_chat_channel_id_fkey";
alter table "public"."apitokens" add constraint "apitokens_chat_channel_id_fkey"
foreign key ("chat_channel_id") references "public"."chat_channel"("id") on update restrict on delete restrict not valid;
alter table "public"."apitokens" validate constraint "apitokens_chat_channel_id_fkey";
create index if not exists apitokens_chat_channel_id_idx
on "public"."apitokens" using btree (chat_channel_id);
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
drop index if exists "public"."apitokens_chat_channel_id_idx";
alter table "public"."apitokens" drop constraint if exists "apitokens_chat_channel_id_fkey";
alter table "public"."apitokens"
drop column if exists "chat_channel_id";
drop index if exists "public"."chat_channel_apitokens_id_idx";
alter table "public"."chat_channel" drop constraint if exists "chat_channel_ai_apitoken_check";
alter table "public"."chat_channel" drop constraint if exists "chat_channel_apitokens_id_fkey";
alter table "public"."chat_channel"
drop column if exists "apitokens_id";
alter table "public"."eventstepinstance" drop constraint if exists "eventstepinstance_user_interaction_resolved_by_fkey";
alter table "public"."eventstepinstance"
@@ -22,4 +22,5 @@ type Apitokens struct {
TaskID structs.NullInt64 `db:"task_id" json:"task_id" mapstructure:"task_id"`
CallbackID structs.NullInt64 `db:"callback_id" json:"callback_id" mapstructure:"callback_id"`
PayloadID structs.NullInt64 `db:"payload_id" json:"payload_id" mapstructure:"payload_id"`
ChatChannelID structs.NullInt64 `db:"chat_channel_id" json:"chat_channel_id" mapstructure:"chat_channel_id"`
}
@@ -41,6 +41,8 @@ type ChatChannel struct {
ChatContainer ConsumingContainer `db:"chat_container" json:"chat_container,omitempty"`
ChatModel string `db:"chat_model" json:"chat_model"`
AIMetadata MythicJSONText `db:"ai_metadata" json:"ai_metadata"`
APITokensID structs.NullInt64 `db:"apitokens_id" json:"api_tokens_id" mapstructure:"apitokens_id"`
APIToken Apitokens `db:"apitoken" json:"apitoken,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
@@ -15,7 +15,7 @@ import (
amqp "github.com/rabbitmq/amqp091-go"
)
const chatStreamFlushInterval = 500 * time.Millisecond
const chatStreamFlushInterval = 750 * time.Millisecond
type ChatContainerResponseMessage struct {
OperationID int `json:"operation_id" mapstructure:"operation_id"`
@@ -15,6 +15,7 @@ type MythicRPCAPITokenCreateMessage struct {
AgentTaskID *string `json:"agent_task_id"`
AgentCallbackID *string `json:"agent_callback_id"`
PayloadUUID *string `json:"payload_uuid"`
ChatChannelID *int `json:"chat_channel_id"`
Scopes []string `json:"scopes"`
}
@@ -44,6 +45,13 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage, authContext R
response.Error = err.Error()
return response
}
if len(normalizedScopes) == 0 {
normalizedScopes, err = mythicjwt.NormalizeAPITokenScopes(authContext.SourceScopes)
if err != nil {
response.Error = err.Error()
return response
}
}
// save off the access_token as an API token and then return it
apiToken := databaseStructs.Apitokens{
TokenValue: "",
@@ -106,8 +114,26 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage, authContext R
apiToken.TokenType = mythicjwt.AUTH_METHOD_CALLBACK
apiToken.CallbackID.Valid = true
apiToken.CallbackID.Int64 = int64(callback.ID)
} else if input.ChatChannelID != nil {
channel := databaseStructs.ChatChannel{}
err = database.DB.Get(&channel, `SELECT id, operation_id, apitokens_id, "name"
FROM chat_channel
WHERE id=$1 AND operation_id=$2 AND channel_type=$3`,
*input.ChatChannelID, authContext.OperationID, databaseStructs.ChatChannelTypeAI)
if err != nil {
response.Error = err.Error()
return response
}
if !channel.APITokensID.Valid || int(channel.APITokensID.Int64) != authContext.APITokensID {
response.Error = "ChatChannelID token delegation requires the RabbitMQ auth context to use the API token associated with that chat channel"
return response
}
apiToken.TokenType = mythicjwt.AUTH_METHOD_CHAT
apiToken.ChatChannelID.Valid = true
apiToken.ChatChannelID.Int64 = int64(channel.ID)
apiToken.Name = fmt.Sprintf("Generated Chat API Token via MythicRPC for Chat Channel \"%s\"", channel.Name)
} else {
response.Error = "No task or callback information provided, can't generate apitoken"
response.Error = "No task, callback, payload, or chat channel information provided, can't generate apitoken"
return response
}
defaultScopes := authContext.SourceScopes
@@ -121,9 +147,9 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage, authContext R
return response
}
statement, err := database.DB.PrepareNamed(`INSERT INTO apitokens
(token_value, operator_id, token_type, active, "name", created_by, task_id, callback_id, payload_id, scopes, eventstepinstance_id)
(token_value, operator_id, token_type, active, "name", created_by, task_id, callback_id, payload_id, scopes, eventstepinstance_id, chat_channel_id)
VALUES
(:token_value, :operator_id, :token_type, :active, :name, :created_by, :task_id, :callback_id, :payload_id, :scopes, :eventstepinstance_id)
(:token_value, :operator_id, :token_type, :active, :name, :created_by, :task_id, :callback_id, :payload_id, :scopes, :eventstepinstance_id, :chat_channel_id)
RETURNING id`)
if err != nil {
response.Error = err.Error()
@@ -149,7 +175,8 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage, authContext R
}
response.Success = true
response.APIToken = accessToken
if apiToken.TokenType == mythicjwt.AUTH_METHOD_CALLBACK || apiToken.TokenType == mythicjwt.AUTH_METHOD_PAYLOAD {
if apiToken.TokenType == mythicjwt.AUTH_METHOD_CALLBACK ||
apiToken.TokenType == mythicjwt.AUTH_METHOD_PAYLOAD {
// deactivate the token after 5 min (should be a short-lived use)
go expireAPITokenAfterShortLivedTTL(apiToken.ID)
}
@@ -18,6 +18,7 @@ type ChatContainerRequestMessage struct {
ContainerName string `json:"container_name" mapstructure:"container_name"`
OperationID int `json:"operation_id" mapstructure:"operation_id"`
ChannelID int `json:"channel_id" mapstructure:"channel_id"`
APITokenID int `json:"apitokens_id" mapstructure:"apitokens_id"`
ChannelName string `json:"channel_name" mapstructure:"channel_name"`
ChannelSlug string `json:"channel_slug" mapstructure:"channel_slug"`
RequestID int `json:"request_id" mapstructure:"request_id"`
@@ -37,14 +38,15 @@ func (r *rabbitMQConnection) SendChatContainerRequest(containerName string, chat
logging.LogError(err, "Failed to generate auth context for chat request")
return err
}
if err = r.SendStructMessage(
err = r.SendStructMessage(
MYTHIC_EXCHANGE,
GetChatContainerRequestRoutingKey(containerName),
"",
chatMessage,
false,
headers,
); err != nil {
)
if err != nil {
logging.LogError(err, "Failed to send chat request", "container", containerName, "request_id", chatMessage.RequestID)
return err
}
@@ -2,10 +2,7 @@ package webcontroller
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
@@ -20,39 +17,6 @@ import (
"github.com/lib/pq"
)
const (
chatContextMessageLimit = 40
chatGeneralChannelName = "general"
)
var chatSlugInvalidCharacters = regexp.MustCompile(`[^a-z0-9]+`)
type ChatActionResponse struct {
Status string `json:"status"`
Error string `json:"error,omitempty"`
ID int `json:"id,omitempty"`
ChannelID int `json:"channel_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
RequestID int `json:"request_id,omitempty"`
ResponseMessageID int `json:"response_message_id,omitempty"`
Results []ChatSearchResult `json:"results,omitempty"`
}
type CreateChatChannelInput struct {
Input CreateChatChannel `json:"input" binding:"required"`
}
type CreateChatChannel struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
ChannelType string `json:"channel_type"`
ChatContainerID *int `json:"chat_container_id"`
ChatModel string `json:"chat_model"`
Locked bool `json:"locked"`
AIMetadata interface{} `json:"ai_metadata"`
APITokenID *int `json:"apitokens_id"`
}
type UpdateChatChannelInput struct {
Input UpdateChatChannel `json:"input" binding:"required"`
}
@@ -67,18 +31,6 @@ type UpdateChatChannel struct {
AIMetadata interface{} `json:"ai_metadata"`
APITokenID *int `json:"apitokens_id"`
}
type CreateChatMessageInput struct {
Input CreateChatMessage `json:"input" binding:"required"`
}
type CreateChatMessage struct {
ChannelID int `json:"channel_id" binding:"required"`
Message string `json:"message" binding:"required"`
SystemMessage bool `json:"system_message"`
AllOperations bool `json:"all_operations"`
}
type EditChatMessageInput struct {
Input EditChatMessage `json:"input" binding:"required"`
}
@@ -147,99 +99,6 @@ type ChatRequestAction struct {
RequestID int `json:"request_id" binding:"required"`
}
func CreateChatChannelWebhook(c *gin.Context) {
var input CreateChatChannelInput
if !bindChatInput(c, &input) {
return
}
operatorOperation, ok := chatOperatorOperation(c)
if !ok {
return
}
channelType := strings.ToLower(strings.TrimSpace(input.Input.ChannelType))
if channelType == "" {
channelType = databaseStructs.ChatChannelTypeStandard
}
if channelType != databaseStructs.ChatChannelTypeStandard && channelType != databaseStructs.ChatChannelTypeAI {
chatRespondError(c, "unknown chat channel type")
return
}
if !chatRequireScope(c, chatScopeForChannelType(channelType, true)) {
return
}
name := strings.TrimSpace(input.Input.Name)
if name == "" {
chatRespondError(c, "channel name is required")
return
}
slug, err := uniqueChatSlug(operatorOperation.CurrentOperation.ID, slugifyChatChannelName(name), 0)
if err != nil {
logging.LogError(err, "Failed to generate unique chat slug")
chatRespondError(c, err.Error())
return
}
var chatContainerID interface{}
var apiTokenID interface{}
if channelType == databaseStructs.ChatChannelTypeAI {
if input.Input.ChatContainerID == nil || *input.Input.ChatContainerID <= 0 {
chatRespondError(c, "AI chat channels require a chat_container_id")
return
}
if input.Input.APITokenID == nil || *input.Input.APITokenID <= 0 {
chatRespondError(c, "AI chat channels require an apitokens_id")
return
}
if _, err = getChatContainer(*input.Input.ChatContainerID); err != nil {
logging.LogError(err, "Failed to find chat container")
chatRespondError(c, "failed to find a chat container with that id")
return
}
if _, err = validateAIChatChannelAPIToken(*input.Input.APITokenID, operatorOperation.CurrentOperation.ID); err != nil {
chatRespondError(c, err.Error())
return
}
chatContainerID = *input.Input.ChatContainerID
apiTokenID = *input.Input.APITokenID
} else if input.Input.APITokenID != nil && *input.Input.APITokenID > 0 {
chatRespondError(c, "apitokens_id can only be set for AI chat channels")
return
}
var lockedBy interface{}
var lockedAt interface{}
if channelType == databaseStructs.ChatChannelTypeAI && input.Input.Locked {
lockedBy = operatorOperation.CurrentOperator.ID
lockedAt = time.Now().UTC()
}
metadata := chatJSONText(input.Input.AIMetadata)
var channelID int
err = database.DB.Get(&channelID, `INSERT INTO chat_channel
(operation_id, name, slug, description, channel_type, created_by, locked, locked_by, locked_at,
chat_container_id, chat_model, ai_metadata, apitokens_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13)
RETURNING id`,
operatorOperation.CurrentOperation.ID,
name,
slug,
input.Input.Description,
channelType,
operatorOperation.CurrentOperator.ID,
channelType == databaseStructs.ChatChannelTypeAI && input.Input.Locked,
lockedBy,
lockedAt,
chatContainerID,
input.Input.ChatModel,
metadata.String(),
apiTokenID,
)
if err != nil {
logging.LogError(err, "Failed to create chat channel")
chatRespondError(c, err.Error())
return
}
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channelID, ChannelID: channelID})
}
func UpdateChatChannelWebhook(c *gin.Context) {
var input UpdateChatChannelInput
if !bindChatInput(c, &input) {
@@ -258,7 +117,7 @@ func UpdateChatChannelWebhook(c *gin.Context) {
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
return
}
if !chatCanManageChannel(operatorOperation, channel) {
if !chatIsModerator(operatorOperation) && channel.CreatedBy != operatorOperation.CurrentOperator.ID {
chatRespondError(c, "only the channel creator or an operation admin can update this channel")
return
}
@@ -399,85 +258,12 @@ func UpdateChatChannelWebhook(c *gin.Context) {
chatRespondError(c, err.Error())
return
}
if archived {
expireGeneratedChatAPITokensForChannel(channel.ID)
}
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channel.ID, ChannelID: channel.ID})
}
func CreateChatMessageWebhook(c *gin.Context) {
var input CreateChatMessageInput
if !bindChatInput(c, &input) {
return
}
operatorOperation, ok := chatOperatorOperation(c)
if !ok {
return
}
message := strings.TrimSpace(input.Input.Message)
if message == "" {
chatRespondError(c, "message is required")
return
}
channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to get chat channel for message")
chatRespondError(c, "failed to find chat channel")
return
}
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
return
}
if input.Input.AllOperations {
if !input.Input.SystemMessage {
chatRespondError(c, "sending to all operations requires a system message")
return
}
if !operatorOperation.CurrentOperator.Admin {
chatRespondError(c, "only a Mythic admin can create system messages in all operations")
return
}
messageID, _, err := insertSystemChatMessageAllOperations(operatorOperation, message, authentication.RabbitMQAuthContextFromGin(c))
if err != nil {
logging.LogError(err, "Failed to create system chat messages in all operations")
chatRespondError(c, err.Error())
return
}
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID})
return
}
if channel.Archived {
chatRespondError(c, "cannot post to an archived chat channel")
return
}
if input.Input.SystemMessage {
if !chatCanCreateSystemMessage(operatorOperation) {
chatRespondError(c, "only an admin can create system messages")
return
}
messageID, err := insertSystemChatMessage(operatorOperation, channel, message, authentication.RabbitMQAuthContextFromGin(c))
if err != nil {
logging.LogError(err, "Failed to create system chat message")
chatRespondError(c, err.Error())
return
}
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID})
return
}
if channel.ChannelType == databaseStructs.ChatChannelTypeAI {
if !chatCanPostToAIChannel(operatorOperation, channel) {
chatRespondError(c, "this AI chat is locked to another operator")
return
}
createAIChatMessage(c, operatorOperation, channel, message, nil)
return
}
messageID, err := insertOperatorChatMessage(operatorOperation, channel, message, authentication.RabbitMQAuthContextFromGin(c))
if err != nil {
logging.LogError(err, "Failed to create chat message")
chatRespondError(c, err.Error())
return
}
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID})
}
func EditChatMessageWebhook(c *gin.Context) {
var input EditChatMessageInput
if !bindChatInput(c, &input) {
@@ -597,11 +383,17 @@ func MarkChatReadWebhook(c *gin.Context) {
lastReadID = maxID.Int64
}
}
if lastReadID == nil {
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ChannelID: channel.ID})
return
}
_, err = database.DB.Exec(`INSERT INTO chat_read_state
(operation_id, channel_id, operator_id, last_read_message_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (operator_id, channel_id) DO UPDATE
SET last_read_message_id=excluded.last_read_message_id`,
SET last_read_message_id=excluded.last_read_message_id
WHERE chat_read_state.last_read_message_id IS NULL
OR chat_read_state.last_read_message_id < excluded.last_read_message_id`,
operatorOperation.CurrentOperation.ID, channel.ID, operatorOperation.CurrentOperator.ID, lastReadID)
if err != nil {
logging.LogError(err, "Failed to mark chat channel read")
@@ -811,169 +603,6 @@ func RetryChatRequestWebhook(c *gin.Context) {
createAIChatMessage(c, operatorOperation, channel, prompt, &request.ID)
}
func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, retryOfID *int) {
container, err := getChatContainer(int(channel.ChatContainerID.Int64))
if err != nil {
logging.LogError(err, "Failed to find AI chat container")
chatRespondError(c, "failed to find AI chat container")
return
}
if !container.ContainerRunning {
chatRespondError(c, fmt.Sprintf("chat container %s is not running", container.Name))
return
}
activeRequestID, err := getActiveAIChatRequest(operatorOperation.CurrentOperation.ID, channel.ID)
if err != nil {
logging.LogError(err, "Failed to check active AI chat request")
chatRespondError(c, err.Error())
return
}
if activeRequestID > 0 {
chatRespondError(c, fmt.Sprintf("AI chat request %d is still in progress; wait for it to finish or cancel it before sending another prompt", activeRequestID))
return
}
chatAuthContext, err := chatChannelAuthContext(channel)
if err != nil {
chatRespondError(c, err.Error())
return
}
operatorAuthContext := authentication.RabbitMQAuthContextFromGin(c)
requestMessageID, err := insertOperatorChatMessage(operatorOperation, channel, message, operatorAuthContext)
if err != nil {
logging.LogError(err, "Failed to create AI prompt message")
chatRespondError(c, err.Error())
return
}
var responseMessageID int
if err = database.DB.Get(&responseMessageID, `INSERT INTO chat_message
(operation_id, channel_id, author_type, chat_container_id, sender_display_name, status, metadata)
VALUES ($1, $2, 'ai', $3, $4, 'pending', $5::jsonb)
RETURNING id`,
operatorOperation.CurrentOperation.ID,
channel.ID,
container.ID,
container.Name,
rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{"model": channel.ChatModel}).String(),
); err != nil {
logging.LogError(err, "Failed to create AI response placeholder")
chatRespondError(c, err.Error())
return
}
var retryOf interface{}
if retryOfID != nil {
retryOf = *retryOfID
}
var requestID int
if err = database.DB.Get(&requestID, `INSERT INTO chat_request
(operation_id, channel_id, request_message_id, response_message_id, chat_container_id,
model, status, context_snapshot, retry_of_id, created_by)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7::jsonb, $8, $9)
RETURNING id`,
operatorOperation.CurrentOperation.ID,
channel.ID,
requestMessageID,
responseMessageID,
container.ID,
channel.ChatModel,
rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{}).String(),
retryOf,
operatorOperation.CurrentOperator.ID,
); err != nil {
logging.LogError(err, "Failed to create AI chat request")
chatRespondError(c, err.Error())
return
}
contextMessages, err := getChatContextMessages(operatorOperation.CurrentOperation.ID, channel.ID, requestMessageID)
if err != nil {
logging.LogError(err, "Failed to fetch AI chat context")
markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error())
chatRespondError(c, err.Error())
return
}
chatConfig := getChatChannelConfig(channel)
contextIDs := make([]int, len(contextMessages))
for i := range contextMessages {
contextIDs[i] = contextMessages[i].ID
}
contextSnapshot := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
"context_message_ids": contextIDs,
"request_message_id": requestMessageID,
"response_message_id": responseMessageID,
"retry_of_id": retryOf,
"context_message_limit": chatContextMessageLimit,
"config": chatConfig,
})
_, _ = database.DB.Exec(`UPDATE chat_request
SET context_snapshot=$3::jsonb
WHERE id=$1 AND operation_id=$2`, requestID, operatorOperation.CurrentOperation.ID, contextSnapshot.String())
err = rabbitmq.RabbitMQConnection.SendChatContainerRequest(container.Name, rabbitmq.ChatContainerRequestMessage{
OperationID: operatorOperation.CurrentOperation.ID,
ChannelID: channel.ID,
APITokenID: int(channel.APITokensID.Int64),
ChannelName: channel.Name,
ChannelSlug: channel.Slug,
RequestID: requestID,
RequestMessageID: requestMessageID,
ResponseMessageID: responseMessageID,
Model: channel.ChatModel,
Prompt: message,
Config: chatConfig,
Context: contextMessages,
Secrets: rabbitmq.GetSecrets(operatorOperation.CurrentOperator.ID, 0),
}, chatAuthContext)
if err != nil {
markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error())
chatRespondError(c, err.Error())
return
}
c.JSON(http.StatusOK, ChatActionResponse{
Status: "success",
ID: requestMessageID,
MessageID: requestMessageID,
ChannelID: channel.ID,
RequestID: requestID,
ResponseMessageID: responseMessageID,
})
}
func bindChatInput(c *gin.Context, input interface{}) bool {
if err := c.ShouldBindJSON(input); err != nil {
logging.LogError(err, "Failed to get JSON parameters for chat webhook")
c.JSON(http.StatusOK, ChatActionResponse{Status: "error", Error: err.Error()})
return false
}
return true
}
func chatOperatorOperation(c *gin.Context) (*databaseStructs.Operatoroperation, bool) {
ginOperatorOperation, ok := c.Get(authentication.ContextKeyOperatorOperationStruct)
if !ok {
chatRespondError(c, "failed to get current operation")
return nil, false
}
operatorOperation := ginOperatorOperation.(*databaseStructs.Operatoroperation)
return operatorOperation, true
}
func chatRespondError(c *gin.Context, err string) {
c.JSON(http.StatusOK, ChatActionResponse{Status: "error", Error: err})
}
func chatRequireScope(c *gin.Context, requiredScope string) bool {
claims, err := authentication.GetClaims(c)
if err != nil {
logging.LogError(err, "Failed to get claims for chat scope check")
chatRespondError(c, "failed to get authentication claims")
return false
}
if !mythicjwt.AllowsScope(claims.Scopes, requiredScope) {
chatRespondError(c, fmt.Sprintf("missing required scope: %s", requiredScope))
return false
}
return true
}
func chatSearchAllowedChannelTypes(c *gin.Context) []string {
claims, err := authentication.GetClaims(c)
if err != nil {
@@ -990,165 +619,6 @@ func chatSearchAllowedChannelTypes(c *gin.Context) []string {
return channelTypes
}
func chatScopeForChannelType(channelType string, write bool) string {
if channelType == databaseStructs.ChatChannelTypeAI {
if write {
return mythicjwt.SCOPE_CHAT_AI_WRITE
}
return mythicjwt.SCOPE_CHAT_AI_READ
}
if write {
return mythicjwt.SCOPE_CHAT_WRITE
}
return mythicjwt.SCOPE_CHAT_READ
}
func chatScopeForChannel(channel databaseStructs.ChatChannel, write bool) string {
return chatScopeForChannelType(channel.ChannelType, write)
}
func chatIsModerator(operatorOperation *databaseStructs.Operatoroperation) bool {
return operatorOperation.CurrentOperator.Admin ||
operatorOperation.ViewMode == database.OPERATOR_OPERATION_VIEW_MODE_LEAD
}
func chatCanCreateSystemMessage(operatorOperation *databaseStructs.Operatoroperation) bool {
return chatIsModerator(operatorOperation)
}
func chatIsGeneralChannel(channel databaseStructs.ChatChannel) bool {
return channel.ChannelType == databaseStructs.ChatChannelTypeStandard &&
strings.EqualFold(channel.Slug, chatGeneralChannelName)
}
func chatCanManageChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool {
return chatIsModerator(operatorOperation) || channel.CreatedBy == operatorOperation.CurrentOperator.ID
}
func chatCanPostToAIChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool {
if channel.ChannelType != databaseStructs.ChatChannelTypeAI {
return true
}
if !channel.Locked {
return true
}
return chatIsModerator(operatorOperation) ||
(channel.LockedBy.Valid && int(channel.LockedBy.Int64) == operatorOperation.CurrentOperator.ID)
}
func slugifyChatChannelName(name string) string {
slug := strings.ToLower(strings.TrimSpace(name))
slug = chatSlugInvalidCharacters.ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-")
if slug == "" {
return "channel"
}
return slug
}
func uniqueChatSlug(operationID int, baseSlug string, excludeChannelID int) (string, error) {
for i := 0; i < 1000; i++ {
candidate := baseSlug
if i > 0 {
candidate = fmt.Sprintf("%s-%d", baseSlug, i+1)
}
var existingID int
err := database.DB.Get(&existingID, `SELECT id FROM chat_channel
WHERE operation_id=$1 AND lower(slug)=lower($2) AND id <> $3`,
operationID, candidate, excludeChannelID)
if err == sql.ErrNoRows {
return candidate, nil
}
if err != nil {
return "", err
}
}
return "", fmt.Errorf("failed to find an available slug for %s", baseSlug)
}
func chatJSONText(input interface{}) databaseStructs.MythicJSONText {
if input == nil {
return rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{})
}
if inputString, ok := input.(string); ok {
var raw json.RawMessage
if err := json.Unmarshal([]byte(inputString), &raw); err == nil {
return rabbitmq.GetMythicJSONTextFromStruct(raw)
}
}
return rabbitmq.GetMythicJSONTextFromStruct(input)
}
func chatMetadataInt(metadata map[string]interface{}, key string) (int, bool) {
value, ok := metadata[key]
if !ok {
return 0, false
}
switch typedValue := value.(type) {
case int:
return typedValue, true
case int64:
return int(typedValue), true
case float64:
return int(typedValue), true
default:
return 0, false
}
}
func getChatChannelConfig(channel databaseStructs.ChatChannel) map[string]interface{} {
metadata := channel.AIMetadata.StructValue()
for _, key := range []string{"config", "configuration"} {
if config, ok := metadata[key].(map[string]interface{}); ok {
return config
}
}
return map[string]interface{}{}
}
func validateAIChatChannelAPIToken(apiTokenID int, operationID int) (databaseStructs.Apitokens, error) {
apiToken := databaseStructs.Apitokens{}
if apiTokenID <= 0 {
return apiToken, fmt.Errorf("AI chat channels require an apitokens_id")
}
err := database.DB.Get(&apiToken, `SELECT apitokens.*
FROM apitokens
JOIN operatoroperation ON operatoroperation.operator_id = apitokens.operator_id
WHERE apitokens.id=$1 AND operatoroperation.operation_id=$2`,
apiTokenID, operationID)
if err != nil {
return apiToken, fmt.Errorf("failed to find an active API token for this operation")
}
if apiToken.Deleted || !apiToken.Active {
return apiToken, fmt.Errorf("AI chat channel API token must be active and not deleted")
}
if apiToken.TokenType != mythicjwt.AUTH_METHOD_API {
return apiToken, fmt.Errorf("AI chat channel API token must be a normal API token")
}
if !mythicjwt.AllowsScope(apiToken.Scopes, mythicjwt.SCOPE_APITOKEN_WRITE) ||
!mythicjwt.AllowsScope(apiToken.Scopes, mythicjwt.SCOPE_CHAT_AI_WRITE) {
return apiToken, fmt.Errorf("AI chat channel API token must include %s and %s",
mythicjwt.SCOPE_APITOKEN_WRITE, mythicjwt.SCOPE_CHAT_AI_WRITE)
}
return apiToken, nil
}
func chatChannelAuthContext(channel databaseStructs.ChatChannel) (rabbitmq.RabbitMQAuthContext, error) {
if !channel.APITokensID.Valid || channel.APITokensID.Int64 <= 0 {
return rabbitmq.RabbitMQAuthContext{}, fmt.Errorf("AI chat channel is missing an API token")
}
apiToken, err := validateAIChatChannelAPIToken(int(channel.APITokensID.Int64), channel.OperationID)
if err != nil {
return rabbitmq.RabbitMQAuthContext{}, err
}
return rabbitmq.RabbitMQAuthContext{
OperatorID: apiToken.OperatorID,
OperationID: channel.OperationID,
APITokensID: apiToken.ID,
SourceScopes: mythicjwt.EffectiveScopes(apiToken.Scopes),
}, nil
}
func expireGeneratedChatAPITokensForChannel(channelID int) {
apitokenIDs := []int{}
if err := database.DB.Select(&apitokenIDs, `SELECT id FROM apitokens
@@ -1167,14 +637,6 @@ func expireGeneratedChatAPITokensForChannel(channelID int) {
}
}
func getChatContainer(containerID int) (databaseStructs.ConsumingContainer, error) {
container := databaseStructs.ConsumingContainer{}
err := database.DB.Get(&container, `SELECT *
FROM consuming_container
WHERE id=$1 AND type=$2 AND deleted=false`, containerID, string(rabbitmq.CONSUMING_SERVICES_TYPE_CHAT))
return container, err
}
func getChatChannel(channelID int, operationID int) (databaseStructs.ChatChannel, error) {
channel := databaseStructs.ChatChannel{}
err := database.DB.Get(&channel, `SELECT *
@@ -1182,202 +644,3 @@ func getChatChannel(channelID int, operationID int) (databaseStructs.ChatChannel
WHERE id=$1 AND operation_id=$2`, channelID, operationID)
return channel, err
}
func getChatMessageAndChannel(messageID int, operationID int) (databaseStructs.ChatMessage, databaseStructs.ChatChannel, error) {
chatMessage := databaseStructs.ChatMessage{}
if err := database.DB.Get(&chatMessage, `SELECT
id,
operation_id,
channel_id,
operator_id,
apitokens_id,
author_type,
chat_container_id,
sender_display_name,
message,
edited,
edited_at,
deleted,
deleted_by,
deleted_at,
status,
metadata,
created_at,
updated_at
FROM chat_message
WHERE id=$1 AND operation_id=$2`, messageID, operationID); err != nil {
return chatMessage, databaseStructs.ChatChannel{}, err
}
channel, err := getChatChannel(chatMessage.ChannelID, operationID)
return chatMessage, channel, err
}
func getChatRequestAndChannel(requestID int, operationID int) (databaseStructs.ChatRequest, databaseStructs.ChatChannel, error) {
request := databaseStructs.ChatRequest{}
if err := database.DB.Get(&request, `SELECT *
FROM chat_request
WHERE id=$1 AND operation_id=$2`, requestID, operationID); err != nil {
return request, databaseStructs.ChatChannel{}, err
}
channel, err := getChatChannel(request.ChannelID, operationID)
return request, channel, err
}
func insertOperatorChatMessage(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, authContext rabbitmq.RabbitMQAuthContext) (int, error) {
var apiTokenID interface{}
if authContext.APITokensID > 0 {
apiTokenID = authContext.APITokensID
}
var messageID int
err := database.DB.Get(&messageID, `INSERT INTO chat_message
(operation_id, channel_id, operator_id, apitokens_id, author_type, sender_display_name, message, status)
VALUES ($1, $2, $3, $4, 'operator', $5, $6, 'complete')
RETURNING id`,
operatorOperation.CurrentOperation.ID,
channel.ID,
operatorOperation.CurrentOperator.ID,
apiTokenID,
operatorOperation.CurrentOperator.Username,
message,
)
return messageID, err
}
func insertSystemChatMessage(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, authContext rabbitmq.RabbitMQAuthContext) (int, error) {
var apiTokenID interface{}
if authContext.APITokensID > 0 {
apiTokenID = authContext.APITokensID
}
metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
"submitted_by_operator_id": operatorOperation.CurrentOperator.ID,
"submitted_by": operatorOperation.CurrentOperator.Username,
})
var messageID int
err := database.DB.Get(&messageID, `INSERT INTO chat_message
(operation_id, channel_id, apitokens_id, author_type, sender_display_name, message, status, metadata)
VALUES ($1, $2, $3, 'system', 'System', $4, 'complete', $5::jsonb)
RETURNING id`,
operatorOperation.CurrentOperation.ID,
channel.ID,
apiTokenID,
message,
metadata.String(),
)
return messageID, err
}
func insertSystemChatMessageAllOperations(operatorOperation *databaseStructs.Operatoroperation, message string, authContext rabbitmq.RabbitMQAuthContext) (int, int, error) {
if err := ensureGeneralChatChannels(); err != nil {
return 0, 0, err
}
var apiTokenID interface{}
if authContext.APITokensID > 0 {
apiTokenID = authContext.APITokensID
}
metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
"submitted_by_operator_id": operatorOperation.CurrentOperator.ID,
"submitted_by": operatorOperation.CurrentOperator.Username,
"all_operations": true,
})
messageIDs := []int{}
err := database.DB.Select(&messageIDs, `INSERT INTO chat_message
(operation_id, channel_id, apitokens_id, author_type, sender_display_name, message, status, metadata)
SELECT chat_channel.operation_id, chat_channel.id, $1, 'system', 'System', $2, 'complete', $3::jsonb
FROM chat_channel
JOIN operation ON operation.id=chat_channel.operation_id
WHERE operation.deleted=false
AND chat_channel.channel_type=$4
AND lower(chat_channel.slug)=$5
RETURNING id`,
apiTokenID,
message,
metadata.String(),
databaseStructs.ChatChannelTypeStandard,
chatGeneralChannelName,
)
if err != nil {
return 0, 0, err
}
if len(messageIDs) == 0 {
return 0, 0, fmt.Errorf("failed to find general channels for any operations")
}
return messageIDs[0], len(messageIDs), nil
}
func ensureGeneralChatChannels() error {
_, err := database.DB.Exec(`INSERT INTO chat_channel
(operation_id, name, slug, description, channel_type, created_by)
SELECT id, $1, $1, 'Default operation chat channel', $2, admin_id
FROM operation
WHERE deleted=false
ON CONFLICT DO NOTHING`,
chatGeneralChannelName,
databaseStructs.ChatChannelTypeStandard,
)
if err != nil {
return err
}
_, err = database.DB.Exec(`UPDATE chat_channel
SET name=$1,
archived=false,
archived_by=NULL,
archived_at=NULL
WHERE channel_type=$2
AND lower(slug)=$1
AND (name <> $1 OR archived=true OR archived_by IS NOT NULL OR archived_at IS NOT NULL)`,
chatGeneralChannelName,
databaseStructs.ChatChannelTypeStandard,
)
return err
}
func getActiveAIChatRequest(operationID int, channelID int) (int, error) {
var requestID int
err := database.DB.Get(&requestID, `SELECT id
FROM chat_request
WHERE operation_id=$1
AND channel_id=$2
AND status IN ('pending', 'streaming')
ORDER BY id DESC
LIMIT 1`, operationID, channelID)
if err == sql.ErrNoRows {
return 0, nil
}
return requestID, err
}
func getChatContextMessages(operationID int, channelID int, lastMessageID int) ([]rabbitmq.ChatContainerContextMessage, error) {
contextMessages := []rabbitmq.ChatContainerContextMessage{}
if err := database.DB.Select(&contextMessages, `SELECT
id,
author_type,
sender_display_name,
message,
created_at
FROM chat_message
WHERE operation_id=$1
AND channel_id=$2
AND id <= $3
AND deleted=false
AND (author_type <> 'ai' OR status='complete')
ORDER BY id DESC
LIMIT $4`, operationID, channelID, lastMessageID, chatContextMessageLimit); err != nil {
return contextMessages, err
}
for i, j := 0, len(contextMessages)-1; i < j; i, j = i+1, j-1 {
contextMessages[i], contextMessages[j] = contextMessages[j], contextMessages[i]
}
return contextMessages, nil
}
func markChatRequestFailed(requestID int, responseMessageID int, operationID int, errorMessage string) {
metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
"error": errorMessage,
})
_, _ = database.DB.Exec(`UPDATE chat_message
SET status='error', metadata=metadata || $3::jsonb
WHERE id=$1 AND operation_id=$2`, responseMessageID, operationID, metadata.String())
_, _ = database.DB.Exec(`UPDATE chat_request
SET status='error', error=$3
WHERE id=$1 AND operation_id=$2`, requestID, operationID, errorMessage)
}
@@ -3,6 +3,7 @@ package webcontroller
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
@@ -14,10 +15,8 @@ import (
"github.com/its-a-feature/Mythic/authentication/mythicjwt"
"github.com/its-a-feature/Mythic/database"
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/lib/pq"
)
const (
@@ -50,20 +49,7 @@ type CreateChatChannel struct {
ChatModel string `json:"chat_model"`
Locked bool `json:"locked"`
AIMetadata interface{} `json:"ai_metadata"`
}
type UpdateChatChannelInput struct {
Input UpdateChatChannel `json:"input" binding:"required"`
}
type UpdateChatChannel struct {
ChannelID int `json:"channel_id" binding:"required"`
Name *string `json:"name"`
Description *string `json:"description"`
Archived *bool `json:"archived"`
Locked *bool `json:"locked"`
ChatModel *string `json:"chat_model"`
AIMetadata interface{} `json:"ai_metadata"`
APITokenID *int `json:"apitokens_id"`
}
type CreateChatMessageInput struct {
@@ -77,74 +63,6 @@ type CreateChatMessage struct {
AllOperations bool `json:"all_operations"`
}
type EditChatMessageInput struct {
Input EditChatMessage `json:"input" binding:"required"`
}
type EditChatMessage struct {
MessageID int `json:"message_id" binding:"required"`
Message string `json:"message" binding:"required"`
}
type DeleteChatMessageInput struct {
Input DeleteChatMessage `json:"input" binding:"required"`
}
type DeleteChatMessage struct {
MessageID int `json:"message_id" binding:"required"`
}
type MarkChatReadInput struct {
Input MarkChatRead `json:"input" binding:"required"`
}
type MarkChatRead struct {
ChannelID int `json:"channel_id" binding:"required"`
LastReadMessageID *int `json:"last_read_message_id"`
}
type RefreshChatSpecialMessageInput struct {
Input RefreshChatSpecialMessage `json:"input" binding:"required"`
}
type RefreshChatSpecialMessage struct {
MessageID int `json:"message_id" binding:"required"`
}
type ChatSearchInput struct {
Input ChatSearch `json:"input" binding:"required"`
}
type ChatSearch struct {
Query string `json:"query" binding:"required"`
ChannelID *int `json:"channel_id"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type ChatSearchResult struct {
ID int `db:"id" json:"id"`
ChannelID int `db:"channel_id" json:"channel_id"`
ChannelName string `db:"channel_name" json:"channel_name"`
ChannelSlug string `db:"channel_slug" json:"channel_slug"`
ChannelType string `db:"channel_type" json:"channel_type"`
AuthorType string `db:"author_type" json:"author_type"`
SenderDisplayName string `db:"sender_display_name" json:"sender_display_name"`
Message string `db:"message" json:"message"`
Edited bool `db:"edited" json:"edited"`
Status string `db:"status" json:"status"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
Rank float64 `db:"rank" json:"rank"`
}
type ChatRequestActionInput struct {
Input ChatRequestAction `json:"input" binding:"required"`
}
type ChatRequestAction struct {
RequestID int `json:"request_id" binding:"required"`
}
func CreateChatChannelWebhook(c *gin.Context) {
var input CreateChatChannelInput
if !bindChatInput(c, &input) {
@@ -162,7 +80,7 @@ func CreateChatChannelWebhook(c *gin.Context) {
chatRespondError(c, "unknown chat channel type")
return
}
if !chatRequireScope(c, chatScopeForChannelType(channelType, true)) {
if !chatRequireScope(c, chatScopeForChannel(databaseStructs.ChatChannel{ChannelType: channelType}, true)) {
return
}
name := strings.TrimSpace(input.Input.Name)
@@ -178,17 +96,30 @@ func CreateChatChannelWebhook(c *gin.Context) {
}
var chatContainerID interface{}
var apiTokenID interface{}
if channelType == databaseStructs.ChatChannelTypeAI {
if input.Input.ChatContainerID == nil || *input.Input.ChatContainerID <= 0 {
chatRespondError(c, "AI chat channels require a chat_container_id")
return
}
if input.Input.APITokenID == nil || *input.Input.APITokenID <= 0 {
chatRespondError(c, "AI chat channels require an apitokens_id")
return
}
if _, err = getChatContainer(*input.Input.ChatContainerID); err != nil {
logging.LogError(err, "Failed to find chat container")
chatRespondError(c, "failed to find a chat container with that id")
return
}
if _, err = validateAIChatChannelAPIToken(*input.Input.APITokenID, operatorOperation.CurrentOperation.ID); err != nil {
chatRespondError(c, err.Error())
return
}
chatContainerID = *input.Input.ChatContainerID
apiTokenID = *input.Input.APITokenID
} else if input.Input.APITokenID != nil && *input.Input.APITokenID > 0 {
chatRespondError(c, "apitokens_id can only be set for AI chat channels")
return
}
var lockedBy interface{}
var lockedAt interface{}
@@ -200,8 +131,8 @@ func CreateChatChannelWebhook(c *gin.Context) {
var channelID int
err = database.DB.Get(&channelID, `INSERT INTO chat_channel
(operation_id, name, slug, description, channel_type, created_by, locked, locked_by, locked_at,
chat_container_id, chat_model, ai_metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
chat_container_id, chat_model, ai_metadata, apitokens_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13)
RETURNING id`,
operatorOperation.CurrentOperation.ID,
name,
@@ -215,6 +146,7 @@ func CreateChatChannelWebhook(c *gin.Context) {
chatContainerID,
input.Input.ChatModel,
metadata.String(),
apiTokenID,
)
if err != nil {
logging.LogError(err, "Failed to create chat channel")
@@ -224,140 +156,6 @@ func CreateChatChannelWebhook(c *gin.Context) {
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channelID, ChannelID: channelID})
}
func UpdateChatChannelWebhook(c *gin.Context) {
var input UpdateChatChannelInput
if !bindChatInput(c, &input) {
return
}
operatorOperation, ok := chatOperatorOperation(c)
if !ok {
return
}
channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to get chat channel")
chatRespondError(c, "failed to find chat channel")
return
}
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
return
}
if !chatCanManageChannel(operatorOperation, channel) {
chatRespondError(c, "only the channel creator or an operation admin can update this channel")
return
}
name := channel.Name
slug := channel.Slug
if input.Input.Name != nil {
name = strings.TrimSpace(*input.Input.Name)
if name == "" {
chatRespondError(c, "channel name is required")
return
}
if chatIsGeneralChannel(channel) {
if name != channel.Name {
chatRespondError(c, "the general channel name cannot be changed")
return
}
} else {
slug, err = uniqueChatSlug(operatorOperation.CurrentOperation.ID, slugifyChatChannelName(name), channel.ID)
if err != nil {
logging.LogError(err, "Failed to generate unique chat slug")
chatRespondError(c, err.Error())
return
}
}
}
description := channel.Description
if input.Input.Description != nil {
description = *input.Input.Description
}
archived := channel.Archived
var archivedBy interface{}
var archivedAt interface{}
if channel.ArchivedBy.Valid {
archivedBy = channel.ArchivedBy.Int64
}
if channel.ArchivedAt.Valid {
archivedAt = channel.ArchivedAt.Time
}
if input.Input.Archived != nil {
archived = *input.Input.Archived
if chatIsGeneralChannel(channel) && archived {
chatRespondError(c, "the general channel cannot be archived")
return
}
if archived {
archivedBy = operatorOperation.CurrentOperator.ID
archivedAt = time.Now().UTC()
} else {
archivedBy = nil
archivedAt = nil
}
}
locked := channel.Locked
var lockedBy interface{}
var lockedAt interface{}
if channel.LockedBy.Valid {
lockedBy = channel.LockedBy.Int64
}
if channel.LockedAt.Valid {
lockedAt = channel.LockedAt.Time
}
if input.Input.Locked != nil {
if channel.ChannelType != databaseStructs.ChatChannelTypeAI {
chatRespondError(c, "only AI chat channels can be locked")
return
}
locked = *input.Input.Locked
if locked {
lockedBy = operatorOperation.CurrentOperator.ID
lockedAt = time.Now().UTC()
} else {
if !chatIsModerator(operatorOperation) &&
(!channel.LockedBy.Valid || int(channel.LockedBy.Int64) != operatorOperation.CurrentOperator.ID) {
chatRespondError(c, "only the lock owner or an operation admin can unlock this AI chat")
return
}
lockedBy = nil
lockedAt = nil
}
}
chatModel := channel.ChatModel
if input.Input.ChatModel != nil {
chatModel = *input.Input.ChatModel
}
metadata := channel.AIMetadata
if input.Input.AIMetadata != nil {
metadata = chatJSONText(input.Input.AIMetadata)
}
_, err = database.DB.Exec(`UPDATE chat_channel
SET name=$3, slug=$4, description=$5, archived=$6, archived_by=$7, archived_at=$8,
locked=$9, locked_by=$10, locked_at=$11, chat_model=$12, ai_metadata=$13::jsonb
WHERE id=$1 AND operation_id=$2`,
channel.ID,
operatorOperation.CurrentOperation.ID,
name,
slug,
description,
archived,
archivedBy,
archivedAt,
locked,
lockedBy,
lockedAt,
chatModel,
metadata.String(),
)
if err != nil {
logging.LogError(err, "Failed to update chat channel")
chatRespondError(c, err.Error())
return
}
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channel.ID, ChannelID: channel.ID})
}
func CreateChatMessageWebhook(c *gin.Context) {
var input CreateChatMessageInput
if !bindChatInput(c, &input) {
@@ -404,7 +202,7 @@ func CreateChatMessageWebhook(c *gin.Context) {
return
}
if input.Input.SystemMessage {
if !chatCanCreateSystemMessage(operatorOperation) {
if !chatIsModerator(operatorOperation) {
chatRespondError(c, "only an admin can create system messages")
return
}
@@ -434,339 +232,6 @@ func CreateChatMessageWebhook(c *gin.Context) {
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID})
}
func EditChatMessageWebhook(c *gin.Context) {
var input EditChatMessageInput
if !bindChatInput(c, &input) {
return
}
operatorOperation, ok := chatOperatorOperation(c)
if !ok {
return
}
message := strings.TrimSpace(input.Input.Message)
if message == "" {
chatRespondError(c, "message is required")
return
}
chatMessage, channel, err := getChatMessageAndChannel(input.Input.MessageID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to get chat message for edit")
chatRespondError(c, "failed to find chat message")
return
}
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
return
}
if chatMessage.Deleted {
chatRespondError(c, "cannot edit a deleted chat message")
return
}
if chatMessage.AuthorType != databaseStructs.ChatMessageAuthorOperator ||
!chatMessage.OperatorID.Valid ||
int(chatMessage.OperatorID.Int64) != operatorOperation.CurrentOperator.ID {
chatRespondError(c, "only the message author can edit this message")
return
}
_, err = database.DB.Exec(`UPDATE chat_message
SET message=$3, edited=true, edited_at=now()
WHERE id=$1 AND operation_id=$2`, chatMessage.ID, operatorOperation.CurrentOperation.ID, message)
if err != nil {
logging.LogError(err, "Failed to edit chat message")
chatRespondError(c, err.Error())
return
}
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: chatMessage.ID, MessageID: chatMessage.ID, ChannelID: channel.ID})
}
func DeleteChatMessageWebhook(c *gin.Context) {
var input DeleteChatMessageInput
if !bindChatInput(c, &input) {
return
}
operatorOperation, ok := chatOperatorOperation(c)
if !ok {
return
}
chatMessage, channel, err := getChatMessageAndChannel(input.Input.MessageID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to get chat message for delete")
chatRespondError(c, "failed to find chat message")
return
}
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
return
}
if !chatIsModerator(operatorOperation) &&
(!chatMessage.OperatorID.Valid || int(chatMessage.OperatorID.Int64) != operatorOperation.CurrentOperator.ID) {
chatRespondError(c, "only the message author or an operation admin can delete this message")
return
}
_, err = database.DB.Exec(`UPDATE chat_message
SET message='Message Deleted',
deleted=true,
deleted_by=$3,
deleted_at=now(),
status='complete'
WHERE id=$1 AND operation_id=$2`, chatMessage.ID, operatorOperation.CurrentOperation.ID, operatorOperation.CurrentOperator.ID)
if err != nil {
logging.LogError(err, "Failed to delete chat message")
chatRespondError(c, err.Error())
return
}
_, _ = database.DB.Exec(`UPDATE chat_request
SET status='cancelled', cancelled_at=now(), error='Chat message was deleted'
WHERE operation_id=$1 AND (request_message_id=$2 OR response_message_id=$2) AND status IN ('pending', 'streaming')`,
operatorOperation.CurrentOperation.ID, chatMessage.ID)
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: chatMessage.ID, MessageID: chatMessage.ID, ChannelID: channel.ID})
}
func MarkChatReadWebhook(c *gin.Context) {
var input MarkChatReadInput
if !bindChatInput(c, &input) {
return
}
operatorOperation, ok := chatOperatorOperation(c)
if !ok {
return
}
channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to get chat channel for read marker")
chatRespondError(c, "failed to find chat channel")
return
}
if !chatRequireScope(c, chatScopeForChannel(channel, false)) {
return
}
var lastReadID interface{}
if input.Input.LastReadMessageID != nil {
lastReadID = *input.Input.LastReadMessageID
} else {
var maxID sql.NullInt64
if err = database.DB.Get(&maxID, `SELECT max(id) FROM chat_message
WHERE operation_id=$1 AND channel_id=$2`, operatorOperation.CurrentOperation.ID, channel.ID); err != nil {
logging.LogError(err, "Failed to get max chat message id")
chatRespondError(c, err.Error())
return
}
if maxID.Valid {
lastReadID = maxID.Int64
}
}
_, err = database.DB.Exec(`INSERT INTO chat_read_state
(operation_id, channel_id, operator_id, last_read_message_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (operator_id, channel_id) DO UPDATE
SET last_read_message_id=excluded.last_read_message_id`,
operatorOperation.CurrentOperation.ID, channel.ID, operatorOperation.CurrentOperator.ID, lastReadID)
if err != nil {
logging.LogError(err, "Failed to mark chat channel read")
chatRespondError(c, err.Error())
return
}
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ChannelID: channel.ID})
}
func RefreshChatSpecialMessageWebhook(c *gin.Context) {
var input RefreshChatSpecialMessageInput
if !bindChatInput(c, &input) {
return
}
operatorOperation, ok := chatOperatorOperation(c)
if !ok {
return
}
chatMessage, channel, err := getChatMessageAndChannel(input.Input.MessageID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to get chat message for refresh")
chatRespondError(c, "failed to find chat message")
return
}
if !chatRequireScope(c, chatScopeForChannel(channel, false)) {
return
}
if chatMessage.Deleted {
chatRespondError(c, "cannot refresh a deleted chat message")
return
}
metadata := chatMessage.Metadata.StructValue()
if metadata["special_type"] != eventing.ChatSpecialTypeEventingUserInteraction {
chatRespondError(c, "chat message is not refreshable")
return
}
eventStepInstanceID, ok := chatMetadataInt(metadata, "eventstepinstance_id")
if !ok || eventStepInstanceID <= 0 {
chatRespondError(c, "chat message is missing its eventstep instance reference")
return
}
message, senderDisplayName, refreshedMetadata, err := eventing.BuildUserInteractionChatMessage(eventStepInstanceID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to refresh eventing user interaction chat message")
chatRespondError(c, err.Error())
return
}
refreshedMetadataText := eventing.GetMythicJSONTextFromStruct(refreshedMetadata)
_, err = database.DB.Exec(`UPDATE chat_message
SET sender_display_name=$3, message=$4, metadata=$5::jsonb, status=$6, updated_at=now()
WHERE id=$1 AND operation_id=$2`,
chatMessage.ID,
operatorOperation.CurrentOperation.ID,
senderDisplayName,
message,
refreshedMetadataText.String(),
databaseStructs.ChatMessageStatusComplete)
if err != nil {
logging.LogError(err, "Failed to update refreshed chat message")
chatRespondError(c, err.Error())
return
}
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: chatMessage.ID, MessageID: chatMessage.ID, ChannelID: channel.ID})
}
func ChatSearchWebhook(c *gin.Context) {
var input ChatSearchInput
if !bindChatInput(c, &input) {
return
}
operatorOperation, ok := chatOperatorOperation(c)
if !ok {
return
}
query := strings.TrimSpace(input.Input.Query)
if query == "" {
chatRespondError(c, "query is required")
return
}
allowedTypes := chatSearchAllowedChannelTypes(c)
if len(allowedTypes) == 0 {
chatRespondError(c, "missing required chat read scope")
return
}
limit := input.Input.Limit
if limit <= 0 || limit > 100 {
limit = 50
}
offset := input.Input.Offset
if offset < 0 {
offset = 0
}
var channelID interface{}
if input.Input.ChannelID != nil {
channelID = *input.Input.ChannelID
}
results := []ChatSearchResult{}
err := database.DB.Select(&results, `SELECT
chat_message.id,
chat_message.channel_id,
chat_channel.name "channel_name",
chat_channel.slug "channel_slug",
chat_channel.channel_type,
chat_message.author_type,
chat_message.sender_display_name,
chat_message.message,
chat_message.edited,
chat_message.status,
chat_message.created_at,
ts_rank(chat_message.search_vector, plainto_tsquery('simple', $3)) "rank"
FROM chat_message
JOIN chat_channel ON chat_message.channel_id = chat_channel.id
WHERE chat_message.operation_id=$1
AND chat_message.deleted=false
AND chat_channel.channel_type = ANY($2)
AND chat_message.search_vector @@ plainto_tsquery('simple', $3)
AND ($4::integer IS NULL OR chat_message.channel_id=$4)
ORDER BY rank DESC, chat_message.id DESC
LIMIT $5 OFFSET $6`,
operatorOperation.CurrentOperation.ID,
pq.Array(allowedTypes),
query,
channelID,
limit,
offset,
)
if err != nil {
logging.LogError(err, "Failed to search chat")
chatRespondError(c, err.Error())
return
}
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", Results: results})
}
func CancelChatRequestWebhook(c *gin.Context) {
var input ChatRequestActionInput
if !bindChatInput(c, &input) {
return
}
operatorOperation, ok := chatOperatorOperation(c)
if !ok {
return
}
request, channel, err := getChatRequestAndChannel(input.Input.RequestID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to find chat request for cancel")
chatRespondError(c, "failed to find chat request")
return
}
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
return
}
if !chatIsModerator(operatorOperation) && request.CreatedBy != operatorOperation.CurrentOperator.ID {
chatRespondError(c, "only the request creator or an operation admin can cancel this request")
return
}
_, err = database.DB.Exec(`UPDATE chat_request
SET status='cancelled', cancelled_at=now(), error='Cancelled by operator'
WHERE id=$1 AND operation_id=$2 AND status IN ('pending', 'streaming')`,
request.ID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to cancel chat request")
chatRespondError(c, err.Error())
return
}
_, _ = database.DB.Exec(`UPDATE chat_message
SET status='cancelled'
WHERE id=$1 AND operation_id=$2 AND status IN ('pending', 'streaming')`,
request.ResponseMessageID, operatorOperation.CurrentOperation.ID)
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", RequestID: request.ID, ResponseMessageID: request.ResponseMessageID, ChannelID: channel.ID})
}
func RetryChatRequestWebhook(c *gin.Context) {
var input ChatRequestActionInput
if !bindChatInput(c, &input) {
return
}
operatorOperation, ok := chatOperatorOperation(c)
if !ok {
return
}
request, channel, err := getChatRequestAndChannel(input.Input.RequestID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to find chat request for retry")
chatRespondError(c, "failed to find chat request")
return
}
if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
return
}
if channel.Archived {
chatRespondError(c, "cannot retry in an archived chat channel")
return
}
if !chatCanPostToAIChannel(operatorOperation, channel) {
chatRespondError(c, "this AI chat is locked to another operator")
return
}
var prompt string
if err = database.DB.Get(&prompt, `SELECT message FROM chat_message
WHERE id=$1 AND operation_id=$2 AND deleted=false`,
request.RequestMessageID, operatorOperation.CurrentOperation.ID); err != nil {
logging.LogError(err, "Failed to get original prompt for retry")
chatRespondError(c, "failed to find original prompt")
return
}
createAIChatMessage(c, operatorOperation, channel, prompt, &request.ID)
}
func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, retryOfID *int) {
container, err := getChatContainer(int(channel.ChatContainerID.Int64))
if err != nil {
@@ -788,8 +253,13 @@ func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Oper
chatRespondError(c, fmt.Sprintf("AI chat request %d is still in progress; wait for it to finish or cancel it before sending another prompt", activeRequestID))
return
}
authContext := authentication.RabbitMQAuthContextFromGin(c)
requestMessageID, err := insertOperatorChatMessage(operatorOperation, channel, message, authContext)
chatAuthContext, err := chatChannelAuthContext(channel)
if err != nil {
chatRespondError(c, err.Error())
return
}
operatorAuthContext := authentication.RabbitMQAuthContextFromGin(c)
requestMessageID, err := insertOperatorChatMessage(operatorOperation, channel, message, operatorAuthContext)
if err != nil {
logging.LogError(err, "Failed to create AI prompt message")
chatRespondError(c, err.Error())
@@ -834,11 +304,46 @@ func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Oper
chatRespondError(c, err.Error())
return
}
contextMessages, err := getChatContextMessages(operatorOperation.CurrentOperation.ID, channel.ID, requestMessageID)
go dispatchAIChatRequest(
operatorOperation.CurrentOperation.ID,
operatorOperation.CurrentOperator.ID,
channel,
container,
requestID,
requestMessageID,
responseMessageID,
message,
retryOf,
chatAuthContext,
)
c.JSON(http.StatusOK, ChatActionResponse{
Status: "success",
ID: requestMessageID,
MessageID: requestMessageID,
ChannelID: channel.ID,
RequestID: requestID,
ResponseMessageID: responseMessageID,
})
}
func dispatchAIChatRequest(
operationID int,
operatorID int,
channel databaseStructs.ChatChannel,
container databaseStructs.ConsumingContainer,
requestID int,
requestMessageID int,
responseMessageID int,
prompt string,
retryOf interface{},
chatAuthContext rabbitmq.RabbitMQAuthContext,
) {
contextMessages, err := getChatContextMessages(operationID, channel.ID, requestMessageID)
if err != nil {
logging.LogError(err, "Failed to fetch AI chat context")
markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error())
chatRespondError(c, err.Error())
markChatRequestFailed(requestID, responseMessageID, operationID, err.Error())
return
}
chatConfig := getChatChannelConfig(channel)
@@ -856,35 +361,27 @@ func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Oper
})
_, _ = database.DB.Exec(`UPDATE chat_request
SET context_snapshot=$3::jsonb
WHERE id=$1 AND operation_id=$2`, requestID, operatorOperation.CurrentOperation.ID, contextSnapshot.String())
WHERE id=$1 AND operation_id=$2`, requestID, operationID, contextSnapshot.String())
err = rabbitmq.RabbitMQConnection.SendChatContainerRequest(container.Name, rabbitmq.ChatContainerRequestMessage{
OperationID: operatorOperation.CurrentOperation.ID,
OperationID: operationID,
ChannelID: channel.ID,
APITokenID: int(channel.APITokensID.Int64),
ChannelName: channel.Name,
ChannelSlug: channel.Slug,
RequestID: requestID,
RequestMessageID: requestMessageID,
ResponseMessageID: responseMessageID,
Model: channel.ChatModel,
Prompt: message,
Prompt: prompt,
Config: chatConfig,
Context: contextMessages,
Secrets: rabbitmq.GetSecrets(operatorOperation.CurrentOperator.ID, 0),
}, authContext)
Secrets: rabbitmq.GetSecrets(operatorID, 0),
}, chatAuthContext)
if err != nil {
markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error())
chatRespondError(c, err.Error())
return
logging.LogError(err, "Failed to send AI chat request", "request_id", requestID, "response_message_id", responseMessageID)
markChatRequestFailed(requestID, responseMessageID, operationID, err.Error())
}
c.JSON(http.StatusOK, ChatActionResponse{
Status: "success",
ID: requestMessageID,
MessageID: requestMessageID,
ChannelID: channel.ID,
RequestID: requestID,
ResponseMessageID: responseMessageID,
})
}
func bindChatInput(c *gin.Context, input interface{}) bool {
@@ -924,24 +421,8 @@ func chatRequireScope(c *gin.Context, requiredScope string) bool {
return true
}
func chatSearchAllowedChannelTypes(c *gin.Context) []string {
claims, err := authentication.GetClaims(c)
if err != nil {
logging.LogError(err, "Failed to get claims for chat search scope check")
return []string{}
}
channelTypes := []string{}
if mythicjwt.AllowsScope(claims.Scopes, mythicjwt.SCOPE_CHAT_READ) {
channelTypes = append(channelTypes, databaseStructs.ChatChannelTypeStandard)
}
if mythicjwt.AllowsScope(claims.Scopes, mythicjwt.SCOPE_CHAT_AI_READ) {
channelTypes = append(channelTypes, databaseStructs.ChatChannelTypeAI)
}
return channelTypes
}
func chatScopeForChannelType(channelType string, write bool) string {
if channelType == databaseStructs.ChatChannelTypeAI {
func chatScopeForChannel(channel databaseStructs.ChatChannel, write bool) string {
if channel.ChannelType == databaseStructs.ChatChannelTypeAI {
if write {
return mythicjwt.SCOPE_CHAT_AI_WRITE
}
@@ -953,37 +434,21 @@ func chatScopeForChannelType(channelType string, write bool) string {
return mythicjwt.SCOPE_CHAT_READ
}
func chatScopeForChannel(channel databaseStructs.ChatChannel, write bool) string {
return chatScopeForChannelType(channel.ChannelType, write)
}
func chatIsModerator(operatorOperation *databaseStructs.Operatoroperation) bool {
return operatorOperation.CurrentOperator.Admin ||
operatorOperation.ViewMode == database.OPERATOR_OPERATION_VIEW_MODE_LEAD
}
func chatCanCreateSystemMessage(operatorOperation *databaseStructs.Operatoroperation) bool {
return chatIsModerator(operatorOperation)
}
func chatIsGeneralChannel(channel databaseStructs.ChatChannel) bool {
return channel.ChannelType == databaseStructs.ChatChannelTypeStandard &&
strings.EqualFold(channel.Slug, chatGeneralChannelName)
}
func chatCanManageChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool {
return chatIsModerator(operatorOperation) || channel.CreatedBy == operatorOperation.CurrentOperator.ID
}
func chatCanPostToAIChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool {
if channel.ChannelType != databaseStructs.ChatChannelTypeAI {
return true
}
if !channel.Locked {
return true
}
return chatIsModerator(operatorOperation) ||
(channel.LockedBy.Valid && int(channel.LockedBy.Int64) == operatorOperation.CurrentOperator.ID)
return channel.LockedBy.Valid && int(channel.LockedBy.Int64) == operatorOperation.CurrentOperator.ID
}
func slugifyChatChannelName(name string) string {
@@ -1006,7 +471,7 @@ func uniqueChatSlug(operationID int, baseSlug string, excludeChannelID int) (str
err := database.DB.Get(&existingID, `SELECT id FROM chat_channel
WHERE operation_id=$1 AND lower(slug)=lower($2) AND id <> $3`,
operationID, candidate, excludeChannelID)
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
return candidate, nil
}
if err != nil {
@@ -1056,6 +521,49 @@ func getChatChannelConfig(channel databaseStructs.ChatChannel) map[string]interf
return map[string]interface{}{}
}
func validateAIChatChannelAPIToken(apiTokenID int, operationID int) (databaseStructs.Apitokens, error) {
apiToken := databaseStructs.Apitokens{}
if apiTokenID <= 0 {
return apiToken, fmt.Errorf("AI chat channels require an apitokens_id")
}
err := database.DB.Get(&apiToken, `SELECT apitokens.*
FROM apitokens
JOIN operatoroperation ON operatoroperation.operator_id = apitokens.operator_id
WHERE apitokens.id=$1 AND operatoroperation.operation_id=$2`,
apiTokenID, operationID)
if err != nil {
return apiToken, fmt.Errorf("failed to find an active API token for this operation")
}
if apiToken.Deleted || !apiToken.Active {
return apiToken, fmt.Errorf("AI chat channel API token must be active and not deleted")
}
if apiToken.TokenType != mythicjwt.AUTH_METHOD_API {
return apiToken, fmt.Errorf("AI chat channel API token must be a normal API token")
}
if !mythicjwt.AllowsScope(apiToken.Scopes, mythicjwt.SCOPE_APITOKEN_WRITE) ||
!mythicjwt.AllowsScope(apiToken.Scopes, mythicjwt.SCOPE_CHAT_AI_WRITE) {
return apiToken, fmt.Errorf("AI chat channel API token must include %s and %s",
mythicjwt.SCOPE_APITOKEN_WRITE, mythicjwt.SCOPE_CHAT_AI_WRITE)
}
return apiToken, nil
}
func chatChannelAuthContext(channel databaseStructs.ChatChannel) (rabbitmq.RabbitMQAuthContext, error) {
if !channel.APITokensID.Valid || channel.APITokensID.Int64 <= 0 {
return rabbitmq.RabbitMQAuthContext{}, fmt.Errorf("AI chat channel is missing an API token")
}
apiToken, err := validateAIChatChannelAPIToken(int(channel.APITokensID.Int64), channel.OperationID)
if err != nil {
return rabbitmq.RabbitMQAuthContext{}, err
}
return rabbitmq.RabbitMQAuthContext{
OperatorID: apiToken.OperatorID,
OperationID: channel.OperationID,
APITokensID: apiToken.ID,
SourceScopes: mythicjwt.EffectiveScopes(apiToken.Scopes),
}, nil
}
func getChatContainer(containerID int) (databaseStructs.ConsumingContainer, error) {
container := databaseStructs.ConsumingContainer{}
err := database.DB.Get(&container, `SELECT *
@@ -1064,14 +572,6 @@ func getChatContainer(containerID int) (databaseStructs.ConsumingContainer, erro
return container, err
}
func getChatChannel(channelID int, operationID int) (databaseStructs.ChatChannel, error) {
channel := databaseStructs.ChatChannel{}
err := database.DB.Get(&channel, `SELECT *
FROM chat_channel
WHERE id=$1 AND operation_id=$2`, channelID, operationID)
return channel, err
}
func getChatMessageAndChannel(messageID int, operationID int) (databaseStructs.ChatMessage, databaseStructs.ChatChannel, error) {
chatMessage := databaseStructs.ChatMessage{}
if err := database.DB.Get(&chatMessage, `SELECT
+108 -24
View File
@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/its-a-feature/Mythic/authentication"
"github.com/its-a-feature/Mythic/database"
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
"github.com/its-a-feature/Mythic/logging"
)
@@ -56,35 +57,60 @@ func Login(c *gin.Context) {
}
func GetMeWebhook(c *gin.Context) {
func GetWhoamiWebhook(c *gin.Context) {
user, err := GetUserIDFromGin(c)
if err != nil {
logging.LogError(err, "Failed to get UserID")
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Failed to get UserID"})
return
}
operator, err := database.GetUserFromID(user)
if err != nil {
logging.LogError(err, "Failed to get operator")
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Failed to get operator"})
return
}
currentOperation, err := database.GetUserCurrentOperation(user)
if err != nil {
logging.LogError(err, "Failed to get current operation")
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Failed to get current operation"})
return
}
//logging.LogInfo("got mewebhook info", "currentOperation", currentOperation)
claims := currentClaimsOrNil(c)
apiTokenInfo := buildWhoamiAPITokenInfo(c, claims)
operations := buildWhoamiOperations(user)
scopes := []string{}
effectiveScopes := []string{}
authMethod := ""
eventStepInstanceID := 0
if claims != nil {
authMethod = claims.AuthMethod
scopes = claims.Scopes
effectiveScopes = mythicjwt.EffectiveScopes(claims.Scopes)
eventStepInstanceID = claims.EventStepInstanceID
}
//logging.LogInfo("got whoami info", "currentOperation", currentOperation)
c.JSON(http.StatusOK, gin.H{
"status": "success",
"current_operation": currentOperation.CurrentOperation.Name,
"current_operation_banner_text": currentOperation.CurrentOperation.BannerText,
"current_operation_banner_color": currentOperation.CurrentOperation.BannerColor,
"current_operation_complete": currentOperation.CurrentOperation.Complete,
"current_operation_id": currentOperation.CurrentOperation.ID,
"username": currentOperation.CurrentOperator.Username,
"user_id": currentOperation.CurrentOperator.ID,
"id": currentOperation.CurrentOperator.ID,
"admin": currentOperation.CurrentOperator.Admin,
"view_mode": currentOperation.ViewMode,
"view_utc_time": currentOperation.CurrentOperator.ViewUtcTime,
"current_utc_time": time.Now().UTC(),
"scope_info": buildScopeIntrospectionResponse(c, currentClaimsOrNil(c)),
"status": "success",
"current_operation": currentOperation.CurrentOperation.Name,
"current_operation_id": currentOperation.CurrentOperation.ID,
"username": operator.Username,
"email": operator.Email.String,
"account_type": operator.AccountType,
"user_id": operator.ID,
"admin": operator.Admin,
"active": operator.Active,
"deleted": operator.Deleted,
"view_mode": currentOperation.ViewMode,
"last_login": operator.LastLogin.Time.UTC().Format(time.RFC3339),
"current_utc_time": time.Now().UTC().Format(time.RFC3339),
"auth_method": authMethod,
"apitoken": apiTokenInfo,
"eventstepinstance_id": eventStepInstanceID,
"scopes": scopes,
"effective_scopes": effectiveScopes,
"operations": operations,
"scope_info": buildScopeIntrospectionResponse(c, claims),
})
return
}
@@ -97,16 +123,74 @@ func currentClaimsOrNil(c *gin.Context) *mythicjwt.CustomClaims {
return claims
}
func buildWhoamiOperations(userID int) []gin.H {
operationSummaries := []gin.H{}
operations, err := database.GetOperationsForUser(userID)
if err != nil {
return operationSummaries
}
for _, operation := range *operations {
operationSummaries = append(operationSummaries, gin.H{
"id": operation.CurrentOperation.ID,
"name": operation.CurrentOperation.Name,
"admin_id": operation.CurrentOperation.AdminID,
"view_mode": operation.ViewMode,
})
}
return operationSummaries
}
func buildWhoamiAPITokenInfo(c *gin.Context, claims *mythicjwt.CustomClaims) gin.H {
apiTokenID := 0
authenticatedWithAPIToken := false
if claims != nil {
apiTokenID = claims.APITokensID
authenticatedWithAPIToken = claims.APITokensID > 0
}
info := gin.H{
"authenticated_with_apitoken": authenticatedWithAPIToken,
"apitokens_id": apiTokenID,
"apitoken_name": nil,
"apitoken_type": nil,
"apitoken_created_by": nil,
"apitoken_creation_time": nil,
"apitoken_active": nil,
"apitoken_deleted": nil,
"apitoken_scopes": []string{},
}
if apiTokenID <= 0 {
return info
}
apiToken := databaseStructs.Apitokens{}
if cachedAPIToken, ok := c.Get(authentication.ContextKeyAPIToken); ok {
if typedAPIToken, ok := cachedAPIToken.(databaseStructs.Apitokens); ok && typedAPIToken.ID == apiTokenID {
apiToken = typedAPIToken
}
}
if apiToken.ID == 0 {
err := database.DB.Get(&apiToken, `SELECT
id, token_type, active, creation_time, operator_id, "name", scopes, created_by, deleted, eventstepinstance_id
FROM apitokens
WHERE id=$1`, apiTokenID)
if err != nil {
logging.LogError(err, "Failed to get API token info for whoami", "apitokens_id", apiTokenID)
return info
}
}
info["apitoken_name"] = apiToken.Name
info["apitoken_type"] = apiToken.TokenType
info["apitoken_created_by"] = apiToken.CreatedBy
info["apitoken_creation_time"] = apiToken.CreationTime.UTC().Format(time.RFC3339)
info["apitoken_active"] = apiToken.Active
info["apitoken_deleted"] = apiToken.Deleted
info["apitoken_scopes"] = []string(apiToken.Scopes)
return info
}
func buildScopeIntrospectionResponse(c *gin.Context, claims *mythicjwt.CustomClaims) gin.H {
if claims == nil {
return gin.H{}
}
operationIDs := []int{}
if operations, err := database.GetOperationsForUser(claims.UserID); err == nil {
for _, operation := range *operations {
operationIDs = append(operationIDs, operation.CurrentOperation.ID)
}
}
effectiveScopes := mythicjwt.EffectiveScopes(claims.Scopes)
hasAllScopes := mythicjwt.AllowsScope(claims.Scopes, mythicjwt.SCOPE_ALL)
scopeDescriptions := []mythicjwt.ScopeDefinition{}
@@ -117,14 +201,14 @@ func buildScopeIntrospectionResponse(c *gin.Context, claims *mythicjwt.CustomCla
}
return gin.H{
"auth_method": claims.AuthMethod,
"apitoken_id": claims.APITokensID,
"apitokens_id": claims.APITokensID,
"operator_id": claims.UserID,
"current_operation_id": claims.OperationID,
"eventstepinstance_id": claims.EventStepInstanceID,
"scopes": claims.Scopes,
"effective_scopes": effectiveScopes,
"scope_descriptions": scopeDescriptions,
"available_scopes": mythicjwt.ScopeDefinitions(),
"operation_ids": operationIDs,
"hasura_scope_claims": mythicjwt.HasuraScopeRequirements(),
"limitations": []string{
"Scopes apply within the operations available to the authenticated operator.",
@@ -2,10 +2,12 @@ package webcontroller
import (
"database/sql"
"errors"
"fmt"
"github.com/its-a-feature/Mythic/rabbitmq"
"net/http"
"github.com/its-a-feature/Mythic/rabbitmq"
"github.com/gin-gonic/gin"
"github.com/its-a-feature/Mythic/database"
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
@@ -68,7 +70,7 @@ func UpdateOperatorOperationWebhook(c *gin.Context) {
}
err = database.DB.Get(&operatorRole, `SELECT * FROM operatoroperation WHERE
operator_id=$1 AND operation_id=$2`, operator.ID, input.Input.OperationID)
if err != sql.ErrNoRows && err != nil {
if !errors.Is(err, sql.ErrNoRows) && err != nil {
logging.LogError(err, "Failed to get information about operator's role in operation")
c.JSON(http.StatusOK, UpdateOperatorOperationResponse{
Status: "error",
@@ -93,26 +95,30 @@ func UpdateOperatorOperationWebhook(c *gin.Context) {
// add users
for _, user := range input.Input.AddUsers {
newUser := databaseStructs.Operator{ID: user}
if _, err := database.DB.Exec(`INSERT INTO operatoroperation
(operator_id, operation_id) VALUES ($1, $2) ON CONFLICT DO NOTHING `, user, currentOperation.ID); err != nil {
_, err = database.DB.Exec(`INSERT INTO operatoroperation
(operator_id, operation_id) VALUES ($1, $2) ON CONFLICT DO NOTHING `, user, currentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to add operator to operation")
} else if err := database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID); err != nil {
continue
}
err = database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID)
if err != nil {
logging.LogError(err, "Failed to lookup operator username")
} else {
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Adding %s to operation", newUser.Username),
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
err = database.DB.Get(&newUser, `SELECT current_operation_id FROM operator WHERE id=$1`, newUser.ID)
if err != nil {
logging.LogError(err, "Failed to lookup operator username")
}
if int(newUser.CurrentOperationID.Int64) == 0 {
newUser.CurrentOperationID.Valid = true
newUser.CurrentOperationID.Int64 = int64(currentOperation.ID)
_, err = database.DB.NamedExec(`UPDATE operator SET current_operation_id=:current_operation_id
continue
}
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Adding %s to operation", newUser.Username),
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
err = database.DB.Get(&newUser, `SELECT current_operation_id FROM operator WHERE id=$1`, newUser.ID)
if err != nil {
logging.LogError(err, "Failed to lookup operator username")
}
if int(newUser.CurrentOperationID.Int64) == 0 {
newUser.CurrentOperationID.Valid = true
newUser.CurrentOperationID.Int64 = int64(currentOperation.ID)
_, err = database.DB.NamedExec(`UPDATE operator SET current_operation_id=:current_operation_id
WHERE id=:id`, newUser)
if err != nil {
logging.LogError(err, "Failed to update operator's current operation")
}
if err != nil {
logging.LogError(err, "Failed to update operator's current operation")
}
}
}
@@ -120,61 +126,81 @@ func UpdateOperatorOperationWebhook(c *gin.Context) {
for _, user := range input.Input.RemoveUsers {
newUser := databaseStructs.Operator{ID: user}
// can't remove the lead this way, you need to use the update first
if _, err := database.DB.Exec(`DELETE FROM operatoroperation
_, err = database.DB.Exec(`DELETE FROM operatoroperation
WHERE operator_id=$1 AND operation_id=$2 AND view_mode!=$3`,
user, currentOperation.ID, database.OPERATOR_OPERATION_VIEW_MODE_LEAD); err != nil {
user, currentOperation.ID, database.OPERATOR_OPERATION_VIEW_MODE_LEAD)
if err != nil {
logging.LogError(err, "Failed to remove operator from operation")
} else if err := database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID); err != nil {
continue
}
err = database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID)
if err != nil {
logging.LogError(err, "Failed to lookup operator username")
} else {
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Removing %s from operation", newUser.Username),
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
// now that a user was removed from the op, see if they have any other's available and update
err = database.DB.Get(&newUser, `SELECT current_operation_id FROM operator WHERE id=$1`, newUser.ID)
if err != nil {
logging.LogError(err, "Failed to lookup operator username")
}
if int(newUser.CurrentOperationID.Int64) == currentOperation.ID {
newUser.CurrentOperationID.Valid = false
newUser.CurrentOperationID.Int64 = 0
_, err = database.DB.NamedExec(`UPDATE operator SET current_operation_id=:current_operation_id
continue
}
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Removing %s from operation", newUser.Username),
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
// now that a user was removed from the op, see if they have any other's available and update
err = database.DB.Get(&newUser, `SELECT current_operation_id FROM operator WHERE id=$1`, newUser.ID)
if err != nil {
logging.LogError(err, "Failed to lookup operator username")
}
err = invalidateAPITokensCreatedByOperatorIDAndOperationID(newUser.ID, newUser.Username, currentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to invalidate API tokens created by operator")
}
if int(newUser.CurrentOperationID.Int64) == currentOperation.ID {
newUser.CurrentOperationID.Valid = false
newUser.CurrentOperationID.Int64 = 0
_, err = database.DB.NamedExec(`UPDATE operator SET current_operation_id=:current_operation_id
WHERE id=:id`, newUser)
if err != nil {
logging.LogError(err, "Failed to update operator's current operation")
}
if err != nil {
logging.LogError(err, "Failed to update operator's current operation")
}
}
}
// change view_mode to operator for specified users
for _, user := range input.Input.ViewModeOperators {
newUser := databaseStructs.Operator{ID: user}
if _, err := database.DB.Exec(`UPDATE operatoroperation SET
_, err = database.DB.Exec(`UPDATE operatoroperation SET
view_mode=$1 WHERE operator_id=$2 AND operation_id=$3 AND view_mode!=$4`,
database.OPERATOR_OPERATION_VIEW_MODE_OPERATOR, user, currentOperation.ID,
database.OPERATOR_OPERATION_VIEW_MODE_LEAD); err != nil {
database.OPERATOR_OPERATION_VIEW_MODE_LEAD)
if err != nil {
logging.LogError(err, "Failed to update view mode to operator")
} else if err := database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID); err != nil {
logging.LogError(err, "Failed to lookup operator username")
} else {
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s to operator", newUser.Username),
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
continue
}
err = database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID)
if err != nil {
logging.LogError(err, "Failed to lookup operator username")
continue
}
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s to operator", newUser.Username),
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
}
// change view_mode to spectator for specified users
for _, user := range input.Input.ViewModeSpectators {
newUser := databaseStructs.Operator{ID: user}
if _, err := database.DB.Exec(`UPDATE operatoroperation SET
_, err = database.DB.Exec(`UPDATE operatoroperation SET
view_mode=$1 WHERE operator_id=$2 AND operation_id=$3 AND view_mode!=$4`,
database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR, user, currentOperation.ID,
database.OPERATOR_OPERATION_VIEW_MODE_LEAD); err != nil {
database.OPERATOR_OPERATION_VIEW_MODE_LEAD)
if err != nil {
logging.LogError(err, "Failed to update view mode to spectator")
} else if err := database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID); err != nil {
logging.LogError(err, "Failed to lookup operator username")
} else {
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s to spectator", newUser.Username),
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
continue
}
err = database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID)
if err != nil {
logging.LogError(err, "Failed to lookup operator username")
continue
}
err = invalidateAPITokensCreatedByOperatorIDAndOperationID(newUser.ID, newUser.Username, currentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to invalidate API tokens created by operator")
}
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s to spectator", newUser.Username),
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
}
// update disabled command profiles
for _, disabledProfile := range input.Input.DisabledCommandProfiles {
@@ -183,19 +209,26 @@ func UpdateOperatorOperationWebhook(c *gin.Context) {
newProfileID = &disabledProfile.DisabledCommandProfileID
}
newUser := databaseStructs.Operator{ID: disabledProfile.UserID}
if _, err := database.DB.Exec(`UPDATE operatoroperation SET base_disabled_commands_id=$1
WHERE operator_id=$2 AND operation_id=$3`, newProfileID, disabledProfile.UserID, currentOperation.ID); err != nil {
_, err = database.DB.Exec(`UPDATE operatoroperation SET base_disabled_commands_id=$1
WHERE operator_id=$2 AND operation_id=$3`, newProfileID, disabledProfile.UserID, currentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to update disabled commands profile")
} else if err := database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID); err != nil {
continue
}
err = database.DB.Get(&newUser, `SELECT username FROM operator WHERE id=$1`, newUser.ID)
if err != nil {
logging.LogError(err, "Failed to lookup operator username")
} else if disabledProfile.DisabledCommandProfileID > 0 {
continue
}
if disabledProfile.DisabledCommandProfileID > 0 {
var profileName string
if err := database.DB.Get(&profileName, `SELECT "name" FROM disabledcommandsprofile WHERE id=$1`, disabledProfile.DisabledCommandProfileID); err != nil {
err = database.DB.Get(&profileName, `SELECT "name" FROM disabledcommandsprofile WHERE id=$1`, disabledProfile.DisabledCommandProfileID)
if err != nil {
logging.LogError(err, "Failed to get disabled commands profile name")
} else {
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s's disabled commands profile to '%s' ", newUser.Username, profileName),
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
continue
}
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Updated %s's disabled commands profile to '%s' ", newUser.Username, profileName),
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
} else {
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Removed %s's disabled commands profile ", newUser.Username),
currentOperation.ID, "", database.MESSAGE_LEVEL_INFO, false)
@@ -6,6 +6,7 @@ import (
"time"
"github.com/its-a-feature/Mythic/authentication/mythicjwt"
"github.com/its-a-feature/Mythic/rabbitmq"
"github.com/gin-gonic/gin"
"github.com/its-a-feature/Mythic/authentication"
@@ -86,10 +87,23 @@ func GenerateAPITokenWebhook(c *gin.Context) {
})
return
}
if !issuingUser.Admin {
issuingUserOperatorOperation := databaseStructs.Operatoroperation{}
err = database.DB.Get(&issuingUserOperatorOperation, `SELECT
id, view_mode
FROM operatoroperation
WHERE operatoroperation.operator_id=$1 and operatoroperation.operation_id=$2`,
issuingUser.ID, targetOperator.CurrentOperationID.Int64)
if err != nil {
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
Status: "error",
Error: "Cannot generate an apitoken for a bot that's a member of an operation you don't belong to",
})
return
}
if issuingUserOperatorOperation.ViewMode == database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR {
c.JSON(http.StatusOK, GenerateAPITokenResponse{
Status: "error",
Error: "only admins can generate API tokens for bot accounts",
Error: "cannot generate an apitoken for a bot as a spectator",
})
return
}
@@ -182,7 +196,38 @@ func GenerateAPITokenWebhook(c *gin.Context) {
Name: apiToken.Name,
CreatedBy: apiToken.CreatedBy,
TokenType: apiToken.TokenType,
Scopes: []string(apiToken.Scopes),
Scopes: apiToken.Scopes,
CreationTime: apiToken.CreationTime,
})
}
func invalidateAPITokensCreatedByOperatorIDAndOperationID(operatorID int, operatorUsername string, operationID int) error {
var err error
if operationID > 0 {
_, err = database.DB.Exec(`UPDATE apitokens
SET active=false, deleted=true
FROM operator bot_operator
WHERE apitokens.created_by=$1
AND apitokens.operator_id = bot_operator.id
AND apitokens.operator_id != apitokens.created_by
AND bot_operator.account_type='bot'
AND bot_operator.current_operation_id=$2
RETURNING apitokens.id
`,
operatorID, operationID)
go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Invalidating APITokens created by %s for bot accounts", operatorUsername),
operationID, "", database.MESSAGE_LEVEL_INFO, false)
} else {
_, err = database.DB.Exec(`UPDATE apitokens
SET active=false, deleted=true
FROM operator bot_operator
WHERE apitokens.created_by=$1
AND apitokens.operator_id = bot_operator.id
AND apitokens.operator_id != apitokens.created_by
AND bot_operator.account_type='bot'
RETURNING apitokens.id`,
operatorID)
}
return err
}
@@ -1,9 +1,10 @@
package webcontroller
import (
"github.com/its-a-feature/Mythic/logging"
"net/http"
"github.com/its-a-feature/Mythic/logging"
"github.com/gin-gonic/gin"
"github.com/its-a-feature/Mythic/database"
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
@@ -42,6 +43,7 @@ func UpdateCurrentOperationWebhook(c *gin.Context) {
// get the associated database information
operatorOperation := databaseStructs.Operatoroperation{}
issuingOperator := databaseStructs.Operator{}
targetOperator := databaseStructs.Operator{}
userID, err := GetUserIDFromGin(c)
if err != nil {
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
@@ -50,7 +52,7 @@ func UpdateCurrentOperationWebhook(c *gin.Context) {
})
return
}
err = database.DB.Get(&issuingOperator, `SELECT id, admin FROM operator WHERE id=$1`, userID)
err = database.DB.Get(&issuingOperator, `SELECT id, admin, account_type FROM operator WHERE id=$1`, userID)
if err != nil {
logging.LogError(err, "Failed to get information about issuing user")
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
@@ -59,10 +61,40 @@ func UpdateCurrentOperationWebhook(c *gin.Context) {
})
return
}
if userID != input.Input.UserID && !issuingOperator.Admin {
err = database.DB.Get(&targetOperator, `SELECT id, admin, account_type, deleted, active FROM operator WHERE id=$1`, input.Input.UserID)
if err != nil {
logging.LogError(err, "Failed to get information about issuing user")
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
Status: "error",
Error: "Cannot set the current operation for another user",
Error: err.Error(),
})
return
}
if targetOperator.Deleted {
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
Status: "error",
Error: "Cannot set the current operation for a deleted user",
})
return
}
if !targetOperator.Active {
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
Status: "error",
Error: "Cannot set the current operation for an inactive user",
})
return
}
if targetOperator.AccountType == databaseStructs.AccountTypeBot {
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
Status: "error",
Error: "Cannot change the current operation for a bot account",
})
return
}
if issuingOperator.ID != input.Input.UserID && !issuingOperator.Admin {
c.JSON(http.StatusOK, UpdateCurrentOperationResponse{
Status: "error",
Error: "Cannot set the current operation for another user unless you're an admin",
})
return
}
+2 -5
View File
@@ -174,11 +174,8 @@ func setRoutes(r *gin.Engine) {
//protected.GET("/graphql/webhook", webcontroller.GetHasuraClaims)
protected.POST("/graphql/webhook", webcontroller.GetHasuraClaims)
// user
protected.POST("/me_webhook",
authentication.TokenScopeMiddleware([]string{
mythicjwt.SCOPE_OPERATOR_READ,
}),
webcontroller.GetMeWebhook) // controller.login
protected.POST("/whoami_webhook",
webcontroller.GetWhoamiWebhook) // controller.login
protected.POST("/generate_apitoken_webhook",
authentication.TokenScopeMiddleware([]string{
mythicjwt.SCOPE_APITOKEN_WRITE,