Finish v1 audit logs, secret versioning, version all unversioned secrets

This commit is contained in:
Tuan Dang
2023-01-02 14:18:49 +07:00
parent 4576e8f6a7
commit a8f0c391bc
19 changed files with 401 additions and 195 deletions
+3 -1
View File
@@ -13,7 +13,8 @@ import { apiLimiter } from './helpers/rateLimiter';
import { import {
workspace as eeWorkspaceRouter, workspace as eeWorkspaceRouter,
secret as eeSecretRouter secret as eeSecretRouter,
action as eeActionRouter
} from './ee/routes/v1'; } from './ee/routes/v1';
import { import {
signup as v1SignupRouter, signup as v1SignupRouter,
@@ -69,6 +70,7 @@ if (NODE_ENV === 'production') {
// (EE) routes // (EE) routes
app.use('/api/v1/secret', eeSecretRouter); app.use('/api/v1/secret', eeSecretRouter);
app.use('/api/v1/workspace', eeWorkspaceRouter); app.use('/api/v1/workspace', eeWorkspaceRouter);
app.use('/api/v1/action', eeActionRouter);
// v1 routes // v1 routes
app.use('/api/v1/signup', v1SignupRouter); app.use('/api/v1/signup', v1SignupRouter);
@@ -369,7 +369,6 @@ export const getWorkspaceServiceTokens = async (
*/ */
export const pushWorkspaceSecrets = async (req: Request, res: Response) => { export const pushWorkspaceSecrets = async (req: Request, res: Response) => {
// upload (encrypted) secrets to workspace with id [workspaceId] // upload (encrypted) secrets to workspace with id [workspaceId]
try { try {
let { secrets }: { secrets: V2PushSecret[] } = req.body; let { secrets }: { secrets: V2PushSecret[] } = req.body;
const { keys, environment, channel } = req.body; const { keys, environment, channel } = req.body;
@@ -400,7 +399,6 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => {
keys keys
}); });
if (postHogClient) { if (postHogClient) {
postHogClient.capture({ postHogClient.capture({
event: 'secrets pushed', event: 'secrets pushed',
@@ -1,18 +1,29 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Action } from '../../models'; import { Action, SecretVersion } from '../../models';
import { ActionNotFoundError } from '../../../utils/errors';
export const getAction = (req: Request, res: Response) => { export const getAction = async (req: Request, res: Response) => {
let action; let action;
// try { try {
// const { actionId } = req.params; const { actionId } = req.params;
// action = await Action.findById(actionId); action = await Action
.findById(actionId)
.populate([
'payload.secretVersions.oldSecretVersion',
'payload.secretVersions.newSecretVersion'
]);
if (!action) throw ActionNotFoundError({
// } catch (err) { message: 'Failed to find action'
});
// } } catch (err) {
throw ActionNotFoundError({
message: 'Failed to find action'
});
}
return res.status(200).send({ return res.status(200).send({
action action
@@ -41,14 +41,23 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => {
let logs let logs
try { try {
const { workspaceId } = req.params; const { workspaceId } = req.params;
const { userId, actionNames } = req.query;
const offset: number = parseInt(req.query.offset as string); const offset: number = parseInt(req.query.offset as string);
const limit: number = parseInt(req.query.limit as string); const limit: number = parseInt(req.query.limit as string);
const filters: any = req.query.filters || {};
filters.workspace = workspaceId; logs = await Log.find({
workspace: workspaceId,
logs = await Log.find(filters) ...( userId ? { user: userId } : {}),
...(
actionNames
? {
actionNames: {
$in: actionNames
}
} : {}
)
})
.skip(offset) .skip(offset)
.limit(limit) .limit(limit)
.populate('actions') .populate('actions')
+112
View File
@@ -0,0 +1,112 @@
import * as Sentry from '@sentry/node';
import { Types } from 'mongoose';
import { Secret } from '../../models';
import { SecretVersion, Action } from '../models';
import { ACTION_UPDATE_SECRETS } from '../../variables';
/**
* Create an (audit) action for secrets including
* add, delete, update, and read actions.
* @param {Object} obj
* @param {String} obj.name - name of action
* @param {ObjectId[]} obj.secretIds - ids of relevant secrets
* @returns {Action} action - new action
*/
const createActionSecretHelper = async ({
name,
userId,
workspaceId,
secretIds
}: {
name: string;
userId: string;
workspaceId: string;
secretIds: Types.ObjectId[];
}) => {
let action;
let latestSecretVersions;
try {
if (name === ACTION_UPDATE_SECRETS) {
// case: action is updating secrets
// -> add old and new secret versions
// TODO: make query more efficient
latestSecretVersions = (await SecretVersion.aggregate([
{
$match: {
secret: {
$in: secretIds,
},
},
},
{
$sort: { version: -1 },
},
{
$group: {
_id: "$secret",
versions: { $push: "$$ROOT" },
},
},
{
$project: {
_id: 0,
secret: "$_id",
versions: { $slice: ["$versions", 2] },
},
}
]))
.map((s) => ({
oldSecretVersion: s.versions[0]._id,
newSecretVersion: s.versions[1]._id
}));
} else {
// case: action is adding, deleting, or reading secrets
// -> add new secret versions
latestSecretVersions = (await SecretVersion.aggregate([
{
$match: {
secret: {
$in: secretIds
}
}
},
{
$group: {
_id: '$secret',
version: { $max: '$version' },
versionId: { $max: '$_id' } // secret version id
}
},
{
$sort: { version: -1 }
}
])
.exec())
.map((s) => ({
newSecretVersion: s.versionId
}));
}
action = await new Action({
name,
user: userId,
workspace: workspaceId,
payload: {
secretVersions: latestSecretVersions
}
}).save();
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to create action');
}
return action;
}
export { createActionSecretHelper };
+1
View File
@@ -22,6 +22,7 @@ const createLogHelper = async ({
log = await new Log({ log = await new Log({
user: userId, user: userId,
workspace: workspaceId, workspace: workspaceId,
actionNames: actions.map((a) => a.name),
actions, actions,
channel, channel,
ipAddress ipAddress
+27 -2
View File
@@ -1,3 +1,4 @@
import { Types } from 'mongoose';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { import {
Secret Secret
@@ -59,16 +60,40 @@ const addSecretVersionsHelper = async ({
}: { }: {
secretVersions: ISecretVersion[] secretVersions: ISecretVersion[]
}) => { }) => {
let newSecretVersions;
try { try {
await SecretVersion.insertMany(secretVersions); newSecretVersions = await SecretVersion.insertMany(secretVersions);
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
Sentry.captureException(err); Sentry.captureException(err);
throw new Error('Failed to add secret versions'); throw new Error('Failed to add secret versions');
} }
return newSecretVersions;
}
const markDeletedSecretVersionsHelper = async ({
secretIds
}: {
secretIds: Types.ObjectId[];
}) => {
try {
await SecretVersion.updateMany({
secret: { $in: secretIds }
}, {
isDeleted: true
}, {
new: true
});
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to mark secret versions as deleted');
}
} }
export { export {
takeSecretSnapshotHelper, takeSecretSnapshotHelper,
addSecretVersionsHelper addSecretVersionsHelper,
markDeletedSecretVersionsHelper
} }
+8 -2
View File
@@ -26,8 +26,14 @@ const actionSchema = new Schema<IAction>(
}, },
payload: { payload: {
secretVersions: [{ secretVersions: [{
type: Schema.Types.ObjectId, oldSecretVersion: {
ref: 'SecretVersion' type: Schema.Types.ObjectId,
ref: 'SecretVersion'
},
newSecretVersion: {
type: Schema.Types.ObjectId,
ref: 'SecretVersion'
}
}] }]
} }
}, { }, {
+19 -1
View File
@@ -1,9 +1,16 @@
import { Schema, model, Types } from 'mongoose'; import { Schema, model, Types } from 'mongoose';
import {
ACTION_ADD_SECRETS,
ACTION_UPDATE_SECRETS,
ACTION_READ_SECRETS,
ACTION_DELETE_SECRETS
} from '../../variables';
export interface ILog { export interface ILog {
_id: Types.ObjectId; _id: Types.ObjectId;
user?: Types.ObjectId; user?: Types.ObjectId;
workspace?: Types.ObjectId; workspace?: Types.ObjectId;
actionNames: string[];
actions: Types.ObjectId[]; actions: Types.ObjectId[];
channel: string; channel: string;
ipAddress?: string; ipAddress?: string;
@@ -19,9 +26,20 @@ const logSchema = new Schema<ILog>(
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'Workspace' ref: 'Workspace'
}, },
actionNames: {
type: [String],
enum: [
ACTION_ADD_SECRETS,
ACTION_UPDATE_SECRETS,
ACTION_READ_SECRETS,
ACTION_DELETE_SECRETS
],
required: true
},
actions: [{ actions: [{
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'Action' ref: 'Action',
required: true
}], }],
channel: { channel: {
type: String, type: String,
-1
View File
@@ -33,7 +33,6 @@ router.get(
param('workspaceId').exists().trim(), param('workspaceId').exists().trim(),
query('offset').exists().isInt(), query('offset').exists().isInt(),
query('limit').exists().isInt(), query('limit').exists().isInt(),
query('filters').exists(),
validateRequest, validateRequest,
workspaceController.getWorkspaceLogs workspaceController.getWorkspaceLogs
); );
+35 -1
View File
@@ -1,10 +1,15 @@
import { Types } from 'mongoose';
import { import {
Log,
Action, Action,
IAction IAction
} from '../models'; } from '../models';
import { import {
createLogHelper createLogHelper
} from '../helpers/log'; } from '../helpers/log';
import {
createActionSecretHelper
} from '../helpers/action';
import EELicenseService from './EELicenseService'; import EELicenseService from './EELicenseService';
/** /**
@@ -19,6 +24,7 @@ class EELogService {
* @param {Action} obj.actions - actions to include in log * @param {Action} obj.actions - actions to include in log
* @param {String} obj.channel - channel (web/cli/auto) associated with the log * @param {String} obj.channel - channel (web/cli/auto) associated with the log
* @param {String} obj.ipAddress - ip address associated with the log * @param {String} obj.ipAddress - ip address associated with the log
* @returns {Log} log - new audit log
*/ */
static async createLog({ static async createLog({
userId, userId,
@@ -33,7 +39,7 @@ class EELogService {
channel: string; channel: string;
ipAddress: string; ipAddress: string;
}) { }) {
if (!EELicenseService.isLicenseValid) return; if (!EELicenseService.isLicenseValid) return null;
return await createLogHelper({ return await createLogHelper({
userId, userId,
workspaceId, workspaceId,
@@ -42,6 +48,34 @@ class EELogService {
ipAddress ipAddress
}) })
} }
/**
* Create an (audit) action for secrets including
* add, delete, update, and read actions.
* @param {Object} obj
* @param {String} obj.name - name of action
* @param {ObjectId[]} obj.secretIds - secret ids
* @returns {Action} action - new action
*/
static async createActionSecret({
name,
userId,
workspaceId,
secretIds
}: {
name: string;
userId: string;
workspaceId: string;
secretIds: Types.ObjectId[];
}) {
if (!EELicenseService.isLicenseValid) return null;
return await createActionSecretHelper({
name,
userId,
workspaceId,
secretIds
});
}
} }
export default EELogService; export default EELogService;
+22 -3
View File
@@ -1,7 +1,9 @@
import { Types } from 'mongoose';
import { ISecretVersion } from '../models'; import { ISecretVersion } from '../models';
import { import {
takeSecretSnapshotHelper, takeSecretSnapshotHelper,
addSecretVersionsHelper addSecretVersionsHelper,
markDeletedSecretVersionsHelper
} from '../helpers/secret'; } from '../helpers/secret';
import EELicenseService from './EELicenseService'; import EELicenseService from './EELicenseService';
@@ -28,7 +30,7 @@ class EESecretService {
} }
/** /**
* Adds secret versions [secretVersions] to the SecretVersion collection. * Add secret versions [secretVersions] to the SecretVersion collection.
* @param {Object} obj * @param {Object} obj
* @param {SecretVersion} obj.secretVersions * @param {SecretVersion} obj.secretVersions
*/ */
@@ -38,10 +40,27 @@ class EESecretService {
secretVersions: ISecretVersion[]; secretVersions: ISecretVersion[];
}) { }) {
if (!EELicenseService.isLicenseValid) return; if (!EELicenseService.isLicenseValid) return;
await addSecretVersionsHelper({ return await addSecretVersionsHelper({
secretVersions secretVersions
}); });
} }
/**
* Mark secret versions associated with secrets with ids [secretIds]
* as deleted.
* @param {Object} obj
* @param {ObjectId[]} obj.secretIds - secret ids
*/
static async markDeletedSecretVersions({
secretIds
}: {
secretIds: Types.ObjectId[];
}) {
if (!EELicenseService.isLicenseValid) return;
await markDeletedSecretVersionsHelper({
secretIds
});
}
} }
export default EESecretService; export default EESecretService;
+78
View File
@@ -0,0 +1,78 @@
import mongoose from 'mongoose';
import { ISecret, Secret } from '../models';
import { EESecretService } from '../ee/services';
import { getLogger } from '../utils/logger';
/**
* Initialize database connection
* @param {Object} obj
* @param {String} obj.mongoURL - mongo connection string
* @returns
*/
const initDatabaseHelper = async ({
mongoURL
}: {
mongoURL: string;
}) => {
try {
await mongoose.connect(mongoURL);
getLogger("database").info("Database connection established");
await prepareDatabase();
} catch (err) {
getLogger("database").error(`Unable to establish Database connection due to the error.\n${err}`);
}
return mongoose.connection;
}
/**
* Prepare database by:
* - Setting unversioned secrets to version 1
* - Initializing secret versions for unversioned secrets
*/
const prepareDatabase = async () => {
try {
// set previously unversioned secrets in Secret to version 1
await Secret.updateMany(
{ version: { $exists: false } },
{ $set: { version: 1 } }
);
// initialize secret versions for unversioned secrets
const unversionedSecrets: ISecret[] = await Secret.aggregate([
{
$lookup: {
from: 'secretversions',
localField: '_id',
foreignField: 'secret',
as: 'versions',
},
},
{
$match: {
versions: { $size: 0 },
},
},
]);
if (unversionedSecrets.length > 0) {
await EESecretService.addSecretVersions({
secretVersions: unversionedSecrets.map((s, idx) => ({
...s,
secret: s._id,
version: s.version ? s.version : 1,
isDeleted: false,
workspace: s.workspace,
environment: s.environment
}))
});
}
} catch (err) {
getLogger('database').error('Failed to prepare database');
}
}
export {
initDatabaseHelper
}
+42 -157
View File
@@ -285,6 +285,9 @@ const v1PushSecrets = async ({
} }
}; };
// TODO: optimize this route.
// TODO: ensure that it's possible to query for and filter logs
/** /**
* Push secrets for user with id [userId] to workspace * Push secrets for user with id [userId] to workspace
* with id [workspaceId] with environment [environment]. Follow steps: * with id [workspaceId] with environment [environment]. Follow steps:
@@ -341,42 +344,19 @@ const v1PushSecrets = async ({
await Secret.deleteMany({ await Secret.deleteMany({
_id: { $in: toDelete } _id: { $in: toDelete }
}); });
await EESecretService.markDeletedSecretVersions({
secretIds: toDelete
});
await SecretVersion.updateMany({ const deleteAction = await EELogService.createActionSecret({
secret: { $in: toDelete } name: ACTION_DELETE_SECRETS,
}, { userId,
isDeleted: true workspaceId,
}, { secretIds: toDelete
new: true
}); });
// add audit log for deleted secrets deleteAction && actions.push(deleteAction);
const deletedLatestSecretVersions = (await SecretVersion.aggregate([
{
$match: { secret: { $in: toDelete } }
},
{
$group: {
_id: '$secret',
version: { $max: '$version' }
}
},
{
$sort: { version: -1 }
}
])
.exec())
.map((s) => s._id);
const deleteAction = await new Action({
name: ACTION_DELETE_SECRETS,
user: new Types.ObjectId(userId),
workspace: new Types.ObjectId(workspaceId),
payload: {
secretVersions: deletedLatestSecretVersions
}
}).save();
actions.push(deleteAction);
} }
const toUpdate = oldSecrets const toUpdate = oldSecrets
@@ -459,10 +439,6 @@ const v1PushSecrets = async ({
secretValueIV, secretValueIV,
secretValueTag, secretValueTag,
secretValueHash, secretValueHash,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentHash,
} = newSecretsObj[`${s.type}-${s.secretKeyHash}`]; } = newSecretsObj[`${s.type}-${s.secretKeyHash}`];
return ({ return ({
@@ -481,34 +457,14 @@ const v1PushSecrets = async ({
}) })
}); });
// add audit log for updated secrets const updateAction = await EELogService.createActionSecret({
const updatedLatestSecretVersions = (await SecretVersion.aggregate([
{
$match: { secret: { $in: toUpdate.map((u) => u._id) } }
},
{
$group: {
_id: '$secret',
version: { $max: '$version' }
}
},
{
$sort: { version: -1 }
}
])
.exec())
.map((s) => s._id);
const updateAction = await new Action({
name: ACTION_UPDATE_SECRETS, name: ACTION_UPDATE_SECRETS,
user: new Types.ObjectId(userId), userId,
workspace: new Types.ObjectId(workspaceId), workspaceId,
payload: { secretIds: toUpdate.map((u) => u._id)
secretVersions: updatedLatestSecretVersions });
}
}).save();
actions.push(updateAction); updateAction && actions.push(updateAction);
} }
// handle adding new secrets // handle adding new secrets
@@ -517,45 +473,14 @@ const v1PushSecrets = async ({
if (toAdd.length > 0) { if (toAdd.length > 0) {
// add secrets // add secrets
const newSecrets = await Secret.insertMany( const newSecrets = await Secret.insertMany(
toAdd.map(({ toAdd.map((s, idx) => ({
secretKeyCiphertext, ...s,
secretKeyIV, version: 1,
secretKeyTag, workspace: workspaceId,
secretKeyHash, type: toAdd[idx].type,
secretValueCiphertext, environment,
secretValueIV, ...( toAdd[idx].type === 'personal' ? { user: userId } : {})
secretValueTag, }))
secretValueHash,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentHash,
}, idx) => {
const obj: any = {
version: 1,
workspace: workspaceId,
type: toAdd[idx].type,
environment,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentHash
};
if (toAdd[idx].type === 'personal') {
obj['user' as keyof typeof obj] = userId;
}
return obj;
})
); );
// (EE) add secret versions for new secrets // (EE) add secret versions for new secrets
@@ -584,35 +509,14 @@ const v1PushSecrets = async ({
secretValueHash secretValueHash
})) }))
}); });
// add audit log for new secrets
const newLatestSecretVersions = (await SecretVersion.aggregate([
{
$match: { secret: { $in: newSecrets.map((n) => n._id) } }
},
{
$group: {
_id: '$secret',
version: { $max: '$version' }
}
},
{
$sort: { version: -1 }
}
])
.exec())
.map((s) => s._id);
const addAction = await new Action({ const addAction = await EELogService.createActionSecret({
name: ACTION_ADD_SECRETS, name: ACTION_ADD_SECRETS,
user: new Types.ObjectId(userId), userId,
workspace: new Types.ObjectId(workspaceId), workspaceId,
payload: { secretIds: newSecrets.map((n) => n._id)
secretVersions: newLatestSecretVersions });
} addAction && actions.push(addAction);
}).save();
actions.push(addAction);
} }
// (EE) take a secret snapshot // (EE) take a secret snapshot
@@ -620,6 +524,7 @@ const v1PushSecrets = async ({
workspaceId workspaceId
}) })
// (EE) create (audit) log
if (actions.length > 0) { if (actions.length > 0) {
await EELogService.createLog({ await EELogService.createLog({
userId, userId,
@@ -637,7 +542,7 @@ const v1PushSecrets = async ({
}; };
/** /**
* Pull secrets for user with id [userId] for workspace * Get secrets for user with id [userId] for workspace
* with id [workspaceId] with environment [environment] * with id [workspaceId] with environment [environment]
* @param {Object} obj * @param {Object} obj
* @param {String} obj.userId -id of user to pull secrets for * @param {String} obj.userId -id of user to pull secrets for
@@ -704,7 +609,7 @@ const pullSecrets = async ({
channel: string; channel: string;
ipAddress: string; ipAddress: string;
}): Promise<ISecret[]> => { }): Promise<ISecret[]> => {
let secrets: any; // TODO: FIX any let secrets: any;
try { try {
secrets = await getSecrets({ secrets = await getSecrets({
@@ -712,35 +617,15 @@ const pullSecrets = async ({
workspaceId, workspaceId,
environment environment
}) })
// add audit log for new secrets
const readLatestSecretVersions = (await SecretVersion.aggregate([
{
$match: { secret: { $in: secrets.map((n: any) => n._id) } }
},
{
$group: {
_id: '$secret',
version: { $max: '$version' }
}
},
{
$sort: { version: -1 }
}
])
.exec())
.map((s) => s._id);
const readAction = await new Action({ const readAction = await EELogService.createActionSecret({
name: ACTION_READ_SECRETS, name: ACTION_READ_SECRETS,
user: new Types.ObjectId(userId), userId,
workspace: new Types.ObjectId(workspaceId), workspaceId,
payload: { secretIds: secrets.map((n: any) => n._id)
secretVersions: readLatestSecretVersions });
}
}).save();
await EELogService.createLog({ readAction && await EELogService.createLog({
userId, userId,
workspaceId, workspaceId,
actions: [readAction], actions: [readAction],
+2 -2
View File
@@ -4,12 +4,12 @@ dotenv.config();
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { SENTRY_DSN, NODE_ENV, MONGO_URL } from './config'; import { SENTRY_DSN, NODE_ENV, MONGO_URL } from './config';
import { server } from './app'; import { server } from './app';
import { initDatabase } from './services/database'; import { DatabaseService } from './services';
import { setUpHealthEndpoint } from './services/health'; import { setUpHealthEndpoint } from './services/health';
import { initSmtp } from './services/smtp'; import { initSmtp } from './services/smtp';
import { setTransporter } from './helpers/nodemailer'; import { setTransporter } from './helpers/nodemailer';
initDatabase(MONGO_URL); DatabaseService.initDatabase(MONGO_URL);
setUpHealthEndpoint(server); setUpHealthEndpoint(server);
+2 -1
View File
@@ -52,7 +52,8 @@ const userSchema = new Schema<IUser>(
}, },
refreshVersion: { refreshVersion: {
type: Number, type: Number,
default: 0 default: 0,
select: false
} }
}, },
{ {
+16
View File
@@ -0,0 +1,16 @@
import mongoose from 'mongoose';
import { getLogger } from '../utils/logger';
import { initDatabaseHelper } from '../helpers/database';
/**
* Class to handle database actions
*/
class DatabaseService {
static async initDatabase(MONGO_URL: string) {
return await initDatabaseHelper({
mongoURL: MONGO_URL
});
}
}
export default DatabaseService;
-10
View File
@@ -1,10 +0,0 @@
import mongoose from 'mongoose';
import { getLogger } from '../utils/logger';
export const initDatabase = (MONGO_URL: string) => {
mongoose
.connect(MONGO_URL)
.then(() => getLogger("database").info("Database connection established"))
.catch((e) => getLogger("database").error(`Unable to establish Database connection due to the error.\n${e}`));
return mongoose.connection;
};
+2
View File
@@ -1,9 +1,11 @@
import DatabaseService from './DatabaseService';
import postHogClient from './PostHogClient'; import postHogClient from './PostHogClient';
import BotService from './BotService'; import BotService from './BotService';
import EventService from './EventService'; import EventService from './EventService';
import IntegrationService from './IntegrationService'; import IntegrationService from './IntegrationService';
export { export {
DatabaseService,
postHogClient, postHogClient,
BotService, BotService,
EventService, EventService,