[FEAT] Automated audit logging (#667)

* WIP event logging - new table for events and new settings view for viewing

* WIP add logging

* UI for log rows

* rename files to Logging to prevent getting gitignore

* add metadata for all logging events and colored badges in logs page

* remove unneeded comment

* cleanup namespace for logging

* clean up backend calls

* update logging to show to => from settings changes

* add logging for invitations, created, deleted, and accepted

* add logging for user created, updated, suspended, or removed

* add logging for workspace deleted

* add logging for chat logs exported

* add logging for API keys, LLM, embedder, vector db, embed chat, and reset button

* modify event logs

* update to event log types

* simplify rendering of event badges

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2024-02-06 15:21:40 -08:00 committed by GitHub
parent 5d64f26066
commit d789920a19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 778 additions and 34 deletions

View File

@ -20,6 +20,7 @@ const AdminUsers = lazy(() => import("@/pages/Admin/Users"));
const AdminInvites = lazy(() => import("@/pages/Admin/Invitations"));
const AdminWorkspaces = lazy(() => import("@/pages/Admin/Workspaces"));
const AdminSystem = lazy(() => import("@/pages/Admin/System"));
const AdminLogs = lazy(() => import("@/pages/Admin/Logging"));
const GeneralChats = lazy(() => import("@/pages/GeneralSettings/Chats"));
const GeneralAppearance = lazy(
() => import("@/pages/GeneralSettings/Appearance")
@ -79,6 +80,10 @@ export default function App() {
path="/settings/vector-database"
element={<AdminRoute Component={GeneralVectorDatabase} />}
/>
<Route
path="/settings/event-logs"
element={<AdminRoute Component={AdminLogs} />}
/>
<Route
path="/settings/embed-config"
element={<AdminRoute Component={EmbedConfigSetup} />}

View File

@ -19,6 +19,7 @@ import {
List,
FileCode,
Plugs,
Notepad,
CodeBlock,
Barcode,
} from "@phosphor-icons/react";
@ -63,7 +64,7 @@ export default function SettingsSidebar() {
{/* Primary Body */}
<div className="h-[100%] flex flex-col w-full justify-between pt-4 overflow-y-hidden">
<div className="h-auto sidebar-items">
<div className="flex flex-col gap-y-2 h-[65vh] pb-8 overflow-y-scroll no-scroll">
<div className="flex flex-col gap-y-2 h-[100%] pb-8 overflow-y-scroll no-scroll">
<Option
href={paths.settings.system()}
btnText="System Preferences"
@ -177,6 +178,14 @@ export default function SettingsSidebar() {
flex={true}
allowedRole={["admin", "manager"]}
/>
<Option
href={paths.settings.logs()}
btnText="Events Logs"
icon={<Notepad className="h-5 w-5 flex-shrink-0" />}
user={user}
flex={true}
allowedRole={["admin"]}
/>
</div>
</div>
<div>
@ -299,7 +308,7 @@ export function SidebarMobileHeader() {
<div className="h-full flex flex-col w-full justify-between pt-4 overflow-y-hidden ">
<div className="h-auto md:sidebar-items md:dark:sidebar-items">
<div
style={{ height: "calc(100vw - -3rem)" }}
style={{ height: "calc(100vw-3rem)" }}
className=" flex flex-col gap-y-4 pb-8 overflow-y-scroll no-scroll"
>
<Option
@ -417,6 +426,14 @@ export function SidebarMobileHeader() {
flex={true}
allowedRole={["admin", "manager"]}
/>
<Option
href={paths.settings.logs()}
btnText="Events Logs"
icon={<Notepad className="h-5 w-5 flex-shrink-0" />}
user={user}
flex={true}
allowedRole={["admin"]}
/>
</div>
</div>
<div>

View File

@ -389,6 +389,29 @@ const System = {
return [];
});
},
eventLogs: async (offset = 0) => {
return await fetch(`${API_BASE}/system/event-logs`, {
method: "POST",
headers: baseHeaders(),
body: JSON.stringify({ offset }),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return [];
});
},
clearEventLogs: async () => {
return await fetch(`${API_BASE}/system/event-logs`, {
method: "DELETE",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return { success: false, error: e.message };
});
},
deleteChat: async (chatId) => {
return await fetch(`${API_BASE}/system/workspace-chats/${chatId}`, {
method: "DELETE",

View File

@ -0,0 +1,105 @@
import { CaretDown, CaretUp } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
export default function LogRow({ log }) {
const [expanded, setExpanded] = useState(false);
const [metadata, setMetadata] = useState(null);
const [hasMetadata, setHasMetadata] = useState(false);
useEffect(() => {
function parseAndSetMetadata() {
try {
let data = JSON.parse(log.metadata);
setHasMetadata(Object.keys(data)?.length > 0);
setMetadata(data);
} catch {}
}
parseAndSetMetadata();
}, [log.metadata]);
const handleRowClick = () => {
if (log.metadata !== "{}") {
setExpanded(!expanded);
}
};
return (
<>
<tr
onClick={handleRowClick}
className={`bg-transparent text-white text-opacity-80 text-sm font-medium ${
hasMetadata ? "cursor-pointer hover:bg-white/5" : ""
}`}
>
<EventBadge event={log.event} />
<td className="px-6 py-4 border-transparent transform transition-transform duration-200">
{log.user.username}
</td>
<td className="px-6 py-4 border-transparent transform transition-transform duration-200">
{log.occurredAt}
</td>
{hasMetadata && (
<>
{expanded ? (
<td
className={`px-2 gap-x-1 flex items-center justify-center transform transition-transform duration-200 hover:scale-105`}
>
<CaretUp weight="bold" size={20} />
<p className="text-xs text-white/50 w-[20px]">hide</p>
</td>
) : (
<td
className={`px-2 gap-x-1 flex items-center justify-center transform transition-transform duration-200 hover:scale-105`}
>
<CaretDown weight="bold" size={20} />
<p className="text-xs text-white/50 w-[20px]">show</p>
</td>
)}
</>
)}
</tr>
<EventMetadata metadata={metadata} expanded={expanded} />
</>
);
}
const EventMetadata = ({ metadata, expanded = false }) => {
if (!metadata || !expanded) return null;
return (
<tr className="bg-sidebar">
<td
colSpan="2"
className="px-6 py-4 font-medium text-white rounded-l-2xl"
>
Event Metadata
</td>
<td colSpan="4" className="px-6 py-4 rounded-r-2xl">
<div className="w-full rounded-lg bg-main-2 p-2 text-white shadow-sm border-white border bg-opacity-10">
<pre className="overflow-scroll">
{JSON.stringify(metadata, null, 2)}
</pre>
</div>
</td>
</tr>
);
};
const EventBadge = ({ event }) => {
let colorTheme = { bg: "bg-sky-600/20", text: "text-sky-400 " };
if (event.includes("update"))
colorTheme = { bg: "bg-yellow-600/20", text: "text-yellow-400 " };
if (event.includes("failed_") || event.includes("deleted"))
colorTheme = { bg: "bg-red-600/20", text: "text-red-400 " };
if (event === "login_event")
colorTheme = { bg: "bg-green-600/20", text: "text-green-400 " };
return (
<td className="px-6 py-4 font-medium whitespace-nowrap text-white flex items-center">
<span
className={`rounded-full ${colorTheme.bg} px-2 py-0.5 text-sm font-medium ${colorTheme.text} shadow-sm`}
>
{event}
</span>
</td>
);
};

View File

@ -0,0 +1,138 @@
import Sidebar, { SidebarMobileHeader } from "@/components/SettingsSidebar";
import useQuery from "@/hooks/useQuery";
import System from "@/models/system";
import { useEffect, useState } from "react";
import { isMobile } from "react-device-detect";
import * as Skeleton from "react-loading-skeleton";
import LogRow from "./LogRow";
import showToast from "@/utils/toast";
export default function AdminLogs() {
const handleResetLogs = async () => {
if (
!window.confirm(
"Are you sure you want to clear all event logs? This action is irreversible."
)
)
return;
const { success, error } = await System.clearEventLogs();
if (success) {
showToast("Event logs cleared successfully.", "success");
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
showToast(`Failed to clear logs: ${error}`, "error");
}
};
return (
<div className="w-screen h-screen overflow-hidden bg-sidebar flex">
{!isMobile && <Sidebar />}
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[26px] bg-main-gradient w-full h-full overflow-y-scroll border-4 border-accent"
>
{isMobile && <SidebarMobileHeader />}
<div className="flex flex-col w-full px-1 md:px-20 md:py-12 py-16">
<div className="w-full flex flex-col gap-y-1 pb-6 border-white border-b-2 border-opacity-10">
<div className="items-center flex gap-x-4">
<p className="text-2xl font-semibold text-white">Event Logs</p>
<button
onClick={handleResetLogs}
className="px-4 py-1 rounded-lg text-slate-200/50 text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800"
>
Clear event logs
</button>
</div>
<p className="text-sm font-base text-white text-opacity-60">
View all actions and events happening on this instance for
monitoring.
</p>
</div>
<LogsContainer />
</div>
</div>
</div>
);
}
function LogsContainer() {
const query = useQuery();
const [loading, setLoading] = useState(true);
const [logs, setLogs] = useState([]);
const [offset, setOffset] = useState(Number(query.get("offset") || 0));
const [canNext, setCanNext] = useState(false);
const handlePrevious = () => {
setOffset(Math.max(offset - 1, 0));
};
const handleNext = () => {
setOffset(offset + 1);
};
useEffect(() => {
async function fetchLogs() {
const { logs: _logs, hasPages = false } = await System.eventLogs(offset);
setLogs(_logs);
setCanNext(hasPages);
setLoading(false);
}
fetchLogs();
}, [offset]);
if (loading) {
return (
<Skeleton.default
height="80vh"
width="100%"
highlightColor="#3D4147"
baseColor="#2C2F35"
count={1}
className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm mt-6"
containerClassName="flex w-full"
/>
);
}
return (
<>
<table className="md:w-5/6 w-full text-sm text-left rounded-lg mt-5">
<thead className="text-white text-opacity-80 text-sm font-bold uppercase border-white border-b border-opacity-60">
<tr>
<th scope="col" className="px-6 py-3">
Event Type
</th>
<th scope="col" className="px-6 py-3">
User
</th>
<th scope="col" className="px-6 py-3">
Occurred At
</th>
<th scope="col" className="px-6 py-3 rounded-tr-lg">
{" "}
</th>
</tr>
</thead>
<tbody>
{!!logs && logs.map((log) => <LogRow key={log.id} log={log} />)}
</tbody>
</table>
<div className="flex w-full justify-between items-center">
<button
onClick={handlePrevious}
className="px-4 py-2 rounded-lg border border-slate-200 text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 disabled:invisible"
disabled={offset === 0}
>
Previous Page
</button>
<button
onClick={handleNext}
className="px-4 py-2 rounded-lg border border-slate-200 text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 disabled:invisible"
disabled={!canNext}
>
Next Page
</button>
</div>
</>
);
}

View File

@ -96,6 +96,9 @@ export default {
apiKeys: () => {
return "/settings/api-keys";
},
logs: () => {
return "/settings/event-logs";
},
embedSetup: () => {
return `/settings/embed-config`;
},

View File

@ -1,7 +1,9 @@
const { ApiKey } = require("../models/apiKeys");
const { Document } = require("../models/documents");
const { EventLogs } = require("../models/eventLogs");
const { Invite } = require("../models/invite");
const { SystemSettings } = require("../models/systemSettings");
const { Telemetry } = require("../models/telemetry");
const { User } = require("../models/user");
const { DocumentVectors } = require("../models/vectors");
const { Workspace } = require("../models/workspace");
@ -56,6 +58,14 @@ function adminEndpoints(app) {
}
const { user: newUser, error } = await User.create(newUserParams);
await EventLogs.logEvent(
"user_created",
{
userName: newUser.username,
createdBy: currUser.username,
},
currUser.id
);
response.status(200).json({ user: newUser, error });
} catch (e) {
console.error(e);
@ -121,6 +131,14 @@ function adminEndpoints(app) {
}
await User.delete({ id: Number(id) });
await EventLogs.logEvent(
"user_deleted",
{
userName: user.username,
deletedBy: currUser.username,
},
currUser.id
);
response.status(200).json({ success: true, error: null });
} catch (e) {
console.error(e);
@ -150,6 +168,14 @@ function adminEndpoints(app) {
try {
const user = await userFromSession(request, response);
const { invite, error } = await Invite.create(user.id);
await EventLogs.logEvent(
"invite_created",
{
inviteCode: invite.code,
createdBy: response.locals?.user?.username,
},
response.locals?.user?.id
);
response.status(200).json({ invite, error });
} catch (e) {
console.error(e);
@ -165,6 +191,11 @@ function adminEndpoints(app) {
try {
const { id } = request.params;
const { success, error } = await Invite.deactivate(id);
await EventLogs.logEvent(
"invite_deleted",
{ deletedBy: response.locals?.user?.username },
response.locals?.user?.id
);
response.status(200).json({ success, error });
} catch (e) {
console.error(e);
@ -323,6 +354,13 @@ function adminEndpoints(app) {
try {
const user = await userFromSession(request, response);
const { apiKey, error } = await ApiKey.create(user.id);
await Telemetry.sendTelemetry("api_key_created");
await EventLogs.logEvent(
"api_key_created",
{ createdBy: user?.username },
user?.id
);
return response.status(200).json({
apiKey,
error,
@ -341,6 +379,12 @@ function adminEndpoints(app) {
try {
const { id } = request.params;
await ApiKey.delete({ id: Number(id) });
await EventLogs.logEvent(
"api_key_deleted",
{ deletedBy: response.locals?.user?.username },
response?.locals?.user?.id
);
return response.status(200).end();
} catch (e) {
console.error(e);

View File

@ -1,3 +1,4 @@
const { EventLogs } = require("../../../models/eventLogs");
const { Invite } = require("../../../models/invite");
const { SystemSettings } = require("../../../models/systemSettings");
const { User } = require("../../../models/user");
@ -259,7 +260,11 @@ function apiAdminEndpoints(app) {
}
const { id } = request.params;
await User.delete({ id });
const user = await User.get({ id: Number(id) });
await User.delete({ id: user.id });
await EventLogs.logEvent("api_user_deleted", {
userName: user.username,
});
response.status(200).json({ success: true, error: null });
} catch (e) {
console.error(e);

View File

@ -12,6 +12,7 @@ const {
findDocumentInDocuments,
} = require("../../../utils/files");
const { reqBody } = require("../../../utils/http");
const { EventLogs } = require("../../../models/eventLogs");
const { handleUploads } = setupMulter();
function apiDocumentEndpoints(app) {
@ -22,7 +23,7 @@ function apiDocumentEndpoints(app) {
[validApiKey],
handleUploads.single("file"),
async (request, response) => {
/*
/*
#swagger.tags = ['Documents']
#swagger.description = 'Upload a new file to AnythingLLM to be parsed and prepared for embedding.'
#swagger.requestBody = {
@ -68,9 +69,9 @@ function apiDocumentEndpoints(app) {
]
}
}
}
}
}
}
}
#swagger.responses[403] = {
schema: {
"$ref": "#/definitions/InvalidAPIKey"
@ -105,6 +106,9 @@ function apiDocumentEndpoints(app) {
`Document ${originalname} uploaded processed and successfully. It is now available in documents.`
);
await Telemetry.sendTelemetry("document_uploaded");
await EventLogs.logEvent("api_document_uploaded", {
documentName: originalname,
});
response.status(200).json({ success: true, error: null, documents });
} catch (e) {
console.log(e.message, e);
@ -117,7 +121,7 @@ function apiDocumentEndpoints(app) {
"/v1/document/upload-link",
[validApiKey],
async (request, response) => {
/*
/*
#swagger.tags = ['Documents']
#swagger.description = 'Upload a valid URL for AnythingLLM to scrape and prepare for embedding.'
#swagger.requestBody = {
@ -132,7 +136,7 @@ function apiDocumentEndpoints(app) {
"link": "https://useanything.com"
}
}
}
}
}
}
#swagger.responses[200] = {
@ -161,9 +165,9 @@ function apiDocumentEndpoints(app) {
]
}
}
}
}
}
}
}
#swagger.responses[403] = {
schema: {
"$ref": "#/definitions/InvalidAPIKey"
@ -196,7 +200,10 @@ function apiDocumentEndpoints(app) {
console.log(
`Link ${link} uploaded processed and successfully. It is now available in documents.`
);
await Telemetry.sendTelemetry("document_uploaded");
await Telemetry.sendTelemetry("link_uploaded");
await EventLogs.logEvent("api_link_uploaded", {
link,
});
response.status(200).json({ success: true, error: null, documents });
} catch (e) {
console.log(e.message, e);
@ -206,7 +213,7 @@ function apiDocumentEndpoints(app) {
);
app.get("/v1/documents", [validApiKey], async (_, response) => {
/*
/*
#swagger.tags = ['Documents']
#swagger.description = 'List of all locally-stored documents in instance'
#swagger.responses[200] = {
@ -231,9 +238,9 @@ function apiDocumentEndpoints(app) {
}
}
}
}
}
}
}
}
#swagger.responses[403] = {
schema: {
"$ref": "#/definitions/InvalidAPIKey"
@ -250,7 +257,7 @@ function apiDocumentEndpoints(app) {
});
app.get("/v1/document/:docName", [validApiKey], async (request, response) => {
/*
/*
#swagger.tags = ['Documents']
#swagger.description = 'Get a single document by its unique AnythingLLM document name'
#swagger.parameters['docName'] = {
@ -281,9 +288,9 @@ function apiDocumentEndpoints(app) {
}
}
}
}
}
}
}
}
#swagger.responses[403] = {
schema: {
"$ref": "#/definitions/InvalidAPIKey"
@ -308,7 +315,7 @@ function apiDocumentEndpoints(app) {
"/v1/document/accepted-file-types",
[validApiKey],
async (_, response) => {
/*
/*
#swagger.tags = ['Documents']
#swagger.description = 'Check available filetypes and MIMEs that can be uploaded.'
#swagger.responses[200] = {
@ -337,9 +344,9 @@ function apiDocumentEndpoints(app) {
}
}
}
}
}
}
}
}
#swagger.responses[403] = {
schema: {
"$ref": "#/definitions/InvalidAPIKey"

View File

@ -16,6 +16,7 @@ const {
writeResponseChunk,
VALID_CHAT_MODE,
} = require("../../../utils/chats/stream");
const { EventLogs } = require("../../../models/eventLogs");
function apiWorkspaceEndpoints(app) {
if (!app) return;
@ -73,6 +74,9 @@ function apiWorkspaceEndpoints(app) {
Embedder: process.env.EMBEDDING_ENGINE || "inherit",
VectorDbSelection: process.env.VECTOR_DB || "pinecone",
});
await EventLogs.logEvent("api_workspace_created", {
workspaceName: workspace?.name || "Unknown Workspace",
});
response.status(200).json({ workspace, message });
} catch (e) {
console.log(e.message, e);
@ -206,6 +210,10 @@ function apiWorkspaceEndpoints(app) {
await DocumentVectors.deleteForWorkspace(workspaceId);
await Document.delete({ workspaceId: workspaceId });
await Workspace.delete({ id: workspaceId });
await EventLogs.logEvent("api_workspace_deleted", {
workspaceName: workspace?.name || "Unknown Workspace",
});
try {
await VectorDb["delete-namespace"]({ namespace: slug });
} catch (e) {
@ -519,6 +527,10 @@ function apiWorkspaceEndpoints(app) {
Embedder: process.env.EMBEDDING_ENGINE || "inherit",
VectorDbSelection: process.env.VECTOR_DB || "pinecone",
});
await EventLogs.logEvent("api_sent_chat", {
workspaceName: workspace?.name,
chatModel: workspace?.chatModel || "System Default",
});
response.status(200).json({ ...result });
} catch (e) {
response.status(500).json({
@ -637,6 +649,10 @@ function apiWorkspaceEndpoints(app) {
Embedder: process.env.EMBEDDING_ENGINE || "inherit",
VectorDbSelection: process.env.VECTOR_DB || "pinecone",
});
await EventLogs.logEvent("api_sent_chat", {
workspaceName: workspace?.name,
chatModel: workspace?.chatModel || "System Default",
});
response.end();
} catch (e) {
console.error(e);

View File

@ -14,6 +14,7 @@ const {
ROLES,
flexUserRoleValid,
} = require("../utils/middleware/multiUserProtected");
const { EventLogs } = require("../models/eventLogs");
function chatEndpoints(app) {
if (!app) return;
@ -98,6 +99,15 @@ function chatEndpoints(app) {
Embedder: process.env.EMBEDDING_ENGINE || "inherit",
VectorDbSelection: process.env.VECTOR_DB || "pinecone",
});
await EventLogs.logEvent(
"sent_chat",
{
workspaceName: workspace?.name,
chatModel: workspace?.chatModel || "System Default",
},
user?.id
);
response.end();
} catch (e) {
console.error(e);

View File

@ -1,5 +1,7 @@
const { EmbedChats } = require("../models/embedChats");
const { EmbedConfig } = require("../models/embedConfig");
const { EventLogs } = require("../models/eventLogs");
const { Workspace } = require("../models/workspace");
const { reqBody, userFromSession } = require("../utils/http");
const { validEmbedConfigId } = require("../utils/middleware/embedMiddleware");
const {
@ -32,9 +34,14 @@ function embedManagementEndpoints(app) {
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const user = userFromSession(request, response);
const user = await userFromSession(request, response);
const data = reqBody(request);
const { embed, message: error } = await EmbedConfig.new(data, user?.id);
await EventLogs.logEvent(
"embed_created",
{ embedId: embed.id },
user?.id
);
response.status(200).json({ embed, error });
} catch (e) {
console.error(e);
@ -48,9 +55,11 @@ function embedManagementEndpoints(app) {
[validatedRequest, flexUserRoleValid([ROLES.admin]), validEmbedConfigId],
async (request, response) => {
try {
const user = await userFromSession(request, response);
const { embedId } = request.params;
const updates = reqBody(request);
const { success, error } = await EmbedConfig.update(embedId, updates);
await EventLogs.logEvent("embed_updated", { embedId }, user?.id);
response.status(200).json({ success, error });
} catch (e) {
console.error(e);
@ -66,6 +75,11 @@ function embedManagementEndpoints(app) {
try {
const { embedId } = request.params;
await EmbedConfig.delete({ id: Number(embedId) });
await EventLogs.logEvent(
"embed_deleted",
{ embedId },
response?.locals?.user?.id
);
response.status(200).json({ success: true, error: null });
} catch (e) {
console.error(e);

View File

@ -1,3 +1,4 @@
const { EventLogs } = require("../models/eventLogs");
const { Invite } = require("../models/invite");
const { User } = require("../models/user");
const { reqBody } = require("../utils/http");
@ -56,6 +57,14 @@ function inviteEndpoints(app) {
}
await Invite.markClaimed(invite.id, user);
await EventLogs.logEvent(
"invite_accepted",
{
username: user.username,
},
user.id
);
response.status(200).json({ success: true, error: null });
} catch (e) {
console.error(e);

View File

@ -48,6 +48,7 @@ const {
prepareWorkspaceChatsForExport,
exportChatsAsType,
} = require("../utils/helpers/chat/convertTo");
const { EventLogs } = require("../models/eventLogs");
function systemEndpoints(app) {
if (!app) return;
@ -114,6 +115,14 @@ function systemEndpoints(app) {
const existingUser = await User.get({ username });
if (!existingUser) {
await EventLogs.logEvent(
"failed_login_invalid_username",
{
ip: request.ip || "Unknown IP",
username: username || "Unknown user",
},
existingUser?.id
);
response.status(200).json({
user: null,
valid: false,
@ -124,6 +133,14 @@ function systemEndpoints(app) {
}
if (!bcrypt.compareSync(password, existingUser.password)) {
await EventLogs.logEvent(
"failed_login_invalid_password",
{
ip: request.ip || "Unknown IP",
username: username || "Unknown user",
},
existingUser?.id
);
response.status(200).json({
user: null,
valid: false,
@ -134,6 +151,14 @@ function systemEndpoints(app) {
}
if (existingUser.suspended) {
await EventLogs.logEvent(
"failed_login_account_suspended",
{
ip: request.ip || "Unknown IP",
username: username || "Unknown user",
},
existingUser?.id
);
response.status(200).json({
user: null,
valid: false,
@ -148,6 +173,16 @@ function systemEndpoints(app) {
{ multiUserMode: false },
existingUser?.id
);
await EventLogs.logEvent(
"login_event",
{
ip: request.ip || "Unknown IP",
username: existingUser.username || "Unknown user",
},
existingUser?.id
);
response.status(200).json({
valid: true,
user: existingUser,
@ -166,6 +201,10 @@ function systemEndpoints(app) {
bcrypt.hashSync(process.env.AUTH_TOKEN, 10)
)
) {
await EventLogs.logEvent("failed_login_invalid_password", {
ip: request.ip || "Unknown IP",
multiUserMode: false,
});
response.status(401).json({
valid: false,
token: null,
@ -175,6 +214,10 @@ function systemEndpoints(app) {
}
await Telemetry.sendTelemetry("login_event", { multiUserMode: false });
await EventLogs.logEvent("login_event", {
ip: request.ip || "Unknown IP",
multiUserMode: false,
});
response.status(200).json({
valid: true,
token: makeJWT({ p: password }, "30d"),
@ -288,7 +331,11 @@ function systemEndpoints(app) {
async (request, response) => {
try {
const body = reqBody(request);
const { newValues, error } = await updateENV(body);
const { newValues, error } = await updateENV(
body,
false,
response?.locals?.user?.id
);
if (process.env.NODE_ENV === "production") await dumpENV();
response.status(200).json({ newValues, error });
} catch (e) {
@ -364,6 +411,7 @@ function systemEndpoints(app) {
await Telemetry.sendTelemetry("enabled_multi_user_mode", {
multiUserMode: true,
});
await EventLogs.logEvent("multi_user_mode_enabled", {}, user?.id);
response.status(200).json({ success: !!user, error });
} catch (e) {
await User.delete({});
@ -694,6 +742,12 @@ function systemEndpoints(app) {
}
const { apiKey, error } = await ApiKey.create();
await Telemetry.sendTelemetry("api_key_created");
await EventLogs.logEvent(
"api_key_created",
{},
response?.locals?.user?.id
);
return response.status(200).json({
apiKey,
error,
@ -715,6 +769,11 @@ function systemEndpoints(app) {
}
await ApiKey.delete();
await EventLogs.logEvent(
"api_key_deleted",
{ deletedBy: response.locals?.user?.username },
response?.locals?.user?.id
);
return response.status(200).end();
} catch (error) {
console.error(error);
@ -744,6 +803,45 @@ function systemEndpoints(app) {
}
);
app.post(
"/system/event-logs",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const { offset = 0, limit = 20 } = reqBody(request);
const logs = await EventLogs.whereWithData({}, limit, offset * limit, {
id: "desc",
});
const totalLogs = await EventLogs.count();
const hasPages = totalLogs > (offset + 1) * limit;
response.status(200).json({ logs: logs, hasPages, totalLogs });
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
}
);
app.delete(
"/system/event-logs",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (_, response) => {
try {
await EventLogs.delete();
await EventLogs.logEvent(
"event_logs_cleared",
{},
response?.locals?.user?.id
);
response.json({ success: true });
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
}
);
app.post(
"/system/workspace-chats",
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
@ -790,6 +888,13 @@ function systemEndpoints(app) {
const { type = "jsonl" } = request.query;
const chats = await prepareWorkspaceChatsForExport();
const { contentType, data } = await exportChatsAsType(chats, type);
await EventLogs.logEvent(
"exported_chats",
{
type,
},
response.locals.user?.id
);
response.setHeader("Content-Type", contentType);
response.status(200).send(data);
} catch (e) {

View File

@ -17,6 +17,7 @@ const {
flexUserRoleValid,
ROLES,
} = require("../utils/middleware/multiUserProtected");
const { EventLogs } = require("../models/eventLogs");
const {
WorkspaceSuggestedMessages,
} = require("../models/workspacesSuggestedMessages");
@ -43,6 +44,14 @@ function workspaceEndpoints(app) {
},
user?.id
);
await EventLogs.logEvent(
"workspace_created",
{
workspaceName: workspace?.name || "Unknown Workspace",
},
user?.id
);
if (onboardingComplete === true)
await Telemetry.sendTelemetry("onboarding_complete");
@ -112,6 +121,13 @@ function workspaceEndpoints(app) {
`Document ${originalname} uploaded processed and successfully. It is now available in documents.`
);
await Telemetry.sendTelemetry("document_uploaded");
await EventLogs.logEvent(
"document_uploaded",
{
documentName: originalname,
},
response.locals?.user?.id
);
response.status(200).json({ success: true, error: null });
}
);
@ -144,6 +160,11 @@ function workspaceEndpoints(app) {
`Link ${link} uploaded processed and successfully. It is now available in documents.`
);
await Telemetry.sendTelemetry("link_uploaded");
await EventLogs.logEvent(
"link_uploaded",
{ link },
response.locals?.user?.id
);
response.status(200).json({ success: true, error: null });
}
);
@ -165,10 +186,15 @@ function workspaceEndpoints(app) {
return;
}
await Document.removeDocuments(currWorkspace, deletes);
await Document.removeDocuments(
currWorkspace,
deletes,
response.locals?.user?.id
);
const { failedToEmbed = [], errors = [] } = await Document.addDocuments(
currWorkspace,
adds
adds,
response.locals?.user?.id
);
const updatedWorkspace = await Workspace.get({ id: currWorkspace.id });
response.status(200).json({
@ -209,6 +235,14 @@ function workspaceEndpoints(app) {
await Document.delete({ workspaceId: Number(workspace.id) });
await Workspace.delete({ id: Number(workspace.id) });
await EventLogs.logEvent(
"workspace_deleted",
{
workspaceName: workspace?.name || "Unknown Workspace",
},
response.locals?.user?.id
);
try {
await VectorDb["delete-namespace"]({ namespace: slug });
} catch (e) {

View File

@ -1,4 +1,3 @@
const { Telemetry } = require("./telemetry");
const prisma = require("../utils/prisma");
const ApiKey = {
@ -19,7 +18,6 @@ const ApiKey = {
},
});
await Telemetry.sendTelemetry("api_key_created");
return { apiKey, error: null };
} catch (error) {
console.error("FAILED TO CREATE API KEY.", error.message);

View File

@ -3,6 +3,7 @@ const { v4: uuidv4 } = require("uuid");
const { getVectorDbClass } = require("../utils/helpers");
const prisma = require("../utils/prisma");
const { Telemetry } = require("./telemetry");
const { EventLogs } = require("./eventLogs");
const Document = {
forWorkspace: async function (workspaceId = null) {
@ -34,7 +35,7 @@ const Document = {
}
},
addDocuments: async function (workspace, additions = []) {
addDocuments: async function (workspace, additions = [], userId = null) {
const VectorDb = getVectorDbClass();
if (additions.length === 0) return { failed: [], embedded: [] };
const embedded = [];
@ -84,10 +85,18 @@ const Document = {
Embedder: process.env.EMBEDDING_ENGINE || "inherit",
VectorDbSelection: process.env.VECTOR_DB || "pinecone",
});
await EventLogs.logEvent(
"workspace_documents_added",
{
workspaceName: workspace?.name || "Unknown Workspace",
numberOfDocumentsAdded: additions.length,
},
userId
);
return { failedToEmbed, errors: Array.from(errors), embedded };
},
removeDocuments: async function (workspace, removals = []) {
removeDocuments: async function (workspace, removals = [], userId = null) {
const VectorDb = getVectorDbClass();
if (removals.length === 0) return;
@ -119,6 +128,14 @@ const Document = {
Embedder: process.env.EMBEDDING_ENGINE || "inherit",
VectorDbSelection: process.env.VECTOR_DB || "pinecone",
});
await EventLogs.logEvent(
"workspace_documents_removed",
{
workspaceName: workspace?.name || "Unknown Workspace",
numberOfDocuments: removals.length,
},
userId
);
return true;
},

129
server/models/eventLogs.js Normal file
View File

@ -0,0 +1,129 @@
const prisma = require("../utils/prisma");
const EventLogs = {
logEvent: async function (event, metadata = {}, userId = null) {
try {
const eventLog = await prisma.event_logs.create({
data: {
event,
metadata: metadata ? JSON.stringify(metadata) : null,
userId: userId ? Number(userId) : null,
occurredAt: new Date(),
},
});
console.log(`\x1b[32m[Event Logged]\x1b[0m - ${event}`);
return { eventLog, message: null };
} catch (error) {
console.error(
`\x1b[31m[Event Logging Failed]\x1b[0m - ${event}`,
error.message
);
return { eventLog: null, message: error.message };
}
},
getByEvent: async function (event, limit = null, orderBy = null) {
try {
const logs = await prisma.event_logs.findMany({
where: { event },
...(limit !== null ? { take: limit } : {}),
...(orderBy !== null
? { orderBy }
: { orderBy: { occurredAt: "desc" } }),
});
return logs;
} catch (error) {
console.error(error.message);
return [];
}
},
getByUserId: async function (userId, limit = null, orderBy = null) {
try {
const logs = await prisma.event_logs.findMany({
where: { userId },
...(limit !== null ? { take: limit } : {}),
...(orderBy !== null
? { orderBy }
: { orderBy: { occurredAt: "desc" } }),
});
return logs;
} catch (error) {
console.error(error.message);
return [];
}
},
where: async function (
clause = {},
limit = null,
orderBy = null,
offset = null
) {
try {
const logs = await prisma.event_logs.findMany({
where: clause,
...(limit !== null ? { take: limit } : {}),
...(offset !== null ? { skip: offset } : {}),
...(orderBy !== null
? { orderBy }
: { orderBy: { occurredAt: "desc" } }),
});
return logs;
} catch (error) {
console.error(error.message);
return [];
}
},
whereWithData: async function (
clause = {},
limit = null,
offset = null,
orderBy = null
) {
const { User } = require("./user");
try {
const results = await this.where(clause, limit, orderBy, offset);
for (const res of results) {
const user = res.userId ? await User.get({ id: res.userId }) : null;
res.user = user
? { username: user.username }
: { username: "unknown user" };
}
return results;
} catch (error) {
console.error(error.message);
return [];
}
},
count: async function (clause = {}) {
try {
const count = await prisma.event_logs.count({
where: clause,
});
return count;
} catch (error) {
console.error(error.message);
return 0;
}
},
delete: async function (clause = {}) {
try {
await prisma.event_logs.deleteMany({
where: clause,
});
return true;
} catch (error) {
console.error(error.message);
return false;
}
},
};
module.exports = { EventLogs };

View File

@ -1,4 +1,5 @@
const prisma = require("../utils/prisma");
const { EventLogs } = require("./eventLogs");
const User = {
create: async function ({ username, password, role = "default" }) {
@ -24,25 +25,52 @@ const User = {
}
},
// Log the changes to a user object, but omit sensitive fields
// that are not meant to be logged.
loggedChanges: function (updates, prev = {}) {
const changes = {};
const sensitiveFields = ["password"];
Object.keys(updates).forEach((key) => {
if (!sensitiveFields.includes(key) && updates[key] !== prev[key]) {
changes[key] = `${prev[key]} => ${updates[key]}`;
}
});
return changes;
},
update: async function (userId, updates = {}) {
try {
// Rehash new password if it exists as update field
const currentUser = await prisma.users.findUnique({
where: { id: parseInt(userId) },
});
if (!currentUser) {
return { success: false, error: "User not found" };
}
if (updates.hasOwnProperty("password")) {
const passwordCheck = this.checkPasswordComplexity(updates.password);
if (!passwordCheck.checkedOK) {
return { success: false, error: passwordCheck.error };
}
const bcrypt = require("bcrypt");
updates.password = bcrypt.hashSync(updates.password, 10);
} else {
delete updates.password;
}
await prisma.users.update({
const user = await prisma.users.update({
where: { id: parseInt(userId) },
data: updates,
});
await EventLogs.logEvent(
"user_updated",
{
username: user.username,
changes: this.loggedChanges(updates, currentUser),
},
userId
);
return { success: true, error: null };
} catch (error) {
console.error(error.message);

View File

@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "event_logs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"event" TEXT NOT NULL,
"metadata" TEXT,
"userId" INTEGER,
"occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE INDEX "event_logs_event_idx" ON "event_logs"("event");

View File

@ -181,3 +181,13 @@ model embed_chats {
embed_config embed_configs @relation(fields: [embed_id], references: [id], onDelete: Cascade)
users users? @relation(fields: [usersId], references: [id])
}
model event_logs {
id Int @id @default(autoincrement())
event String
metadata String?
userId Int?
occurredAt DateTime @default(now())
@@index([event])
}

View File

@ -430,7 +430,7 @@ async function wipeWorkspaceModelPreference(key, prev, next) {
// read from an ENV file as this seems to be a complicating step for many so allowing people to write
// to the process will at least alleviate that issue. It does not perform comprehensive validity checks or sanity checks
// and is simply for debugging when the .env not found issue many come across.
async function updateENV(newENVs = {}, force = false) {
async function updateENV(newENVs = {}, force = false, userId = null) {
let error = "";
const validKeys = Object.keys(KEY_MAPPING);
const ENV_KEYS = Object.keys(newENVs).filter(
@ -458,9 +458,25 @@ async function updateENV(newENVs = {}, force = false) {
await postUpdateFunc(key, prevValue, nextValue);
}
await logChangesToEventLog(newValues, userId);
return { newValues, error: error?.length > 0 ? error : false };
}
async function logChangesToEventLog(newValues = {}, userId = null) {
const { EventLogs } = require("../../models/eventLogs");
const eventMapping = {
LLMProvider: "update_llm_provider",
EmbeddingEngine: "update_embedding_engine",
VectorDB: "update_vector_db",
};
for (const [key, eventName] of Object.entries(eventMapping)) {
if (!newValues.hasOwnProperty(key)) continue;
await EventLogs.logEvent(eventName, {}, userId);
}
return;
}
async function dumpENV() {
const fs = require("fs");
const path = require("path");