From 4ad4efe9a5aef1141c76ec7df6a52c41caa8a3cd Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Thu, 15 Dec 2022 23:35:52 -0500 Subject: [PATCH 01/46] Added a basic framework for activity logs --- frontend/components/basic/Layout.tsx | 6 + .../components/basic/table/ActivityTable.tsx | 144 ++++++++++++++++++ frontend/pages/activity/[id].tsx | 62 ++++++++ 3 files changed, 212 insertions(+) create mode 100644 frontend/components/basic/table/ActivityTable.tsx create mode 100644 frontend/pages/activity/[id].tsx diff --git a/frontend/components/basic/Layout.tsx b/frontend/components/basic/Layout.tsx index 56a16cb3d..91f257ff9 100644 --- a/frontend/components/basic/Layout.tsx +++ b/frontend/components/basic/Layout.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { + faBook, faBookOpen, faGear, faKey, @@ -150,6 +151,11 @@ export default function Layout({ children }: LayoutProps) { title: 'Integrations', emoji: }, + { + href: '/activity/' + workspaceMapping[workspaceSelected as any], + title: 'Activity', + emoji: + }, { href: '/settings/project/' + workspaceMapping[workspaceSelected as any], title: 'Project Settings', diff --git a/frontend/components/basic/table/ActivityTable.tsx b/frontend/components/basic/table/ActivityTable.tsx new file mode 100644 index 000000000..fe98277e7 --- /dev/null +++ b/frontend/components/basic/table/ActivityTable.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { + faAngleDown, + faAngleRight, + faX +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import guidGenerator from '../../utilities/randomId'; + +interface ActivityTableProps { + eventName: string; + user: string; + source: string; + time: Date; +} + +function timeSince(date: Date) { + const seconds = Math.floor( + ((new Date() as any) - (date as any)) / 1000 + ) as number; + + let interval = seconds / 31536000; + + if (interval > 1) { + return Math.floor(interval) + ' years ago'; + } + interval = seconds / 2592000; + if (interval > 1) { + return Math.floor(interval) + ' months ago'; + } + interval = seconds / 86400; + if (interval > 1) { + return Math.floor(interval) + ' days ago'; + } + interval = seconds / 3600; + if (interval > 1) { + return Math.floor(interval) + ' hours ago'; + } + interval = seconds / 60; + if (interval > 1) { + return Math.floor(interval) + ' minutes ago'; + } + return Math.floor(seconds) + ' seconds ago'; +} + +const ActivityLogsRow = ({ row }: { row: ActivityTableProps }): JSX.Element => { + const [payloadOpened, setPayloadOpened] = useState(false); + return ( + <> + +
setPayloadOpened(!payloadOpened)} + className="border-mineshaft-700 border-t text-gray-300 flex items-center" + > + +
+ + {row.eventName} + + + {row.user} + + + {row.source} + + + {timeSince(row.time)} + + {/* +
+
+ */} + + {payloadOpened && ( + + +
+
Timestamp
+
2022-12-16T04:02:44.517Z
+
+
+
Number of Secrets
+
32
+
+
+
IP Address
+
159.223.164.24
+
+ + + )} + + ); +}; + +/** + * This is the table for activity logs (one of the tabs) + * @param {*} props + * @returns + */ +const ActivityTable = ({ data }: { data: ActivityTableProps[] }) => { + return ( +
+
+
+ + + + + + + + + + + + + {data.map((row, index) => { + return ; + })} + +
EventUserSourceTime
+
+
+ ); +}; + +export default ActivityTable; diff --git a/frontend/pages/activity/[id].tsx b/frontend/pages/activity/[id].tsx new file mode 100644 index 000000000..5ffba0328 --- /dev/null +++ b/frontend/pages/activity/[id].tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; + +import ActivityTable from '~/components/basic/table/ActivityTable'; +import NavHeader from '~/components/navigation/NavHeader'; +import onboardingCheck from '~/components/utilities/checks/OnboardingCheck'; + +const data = [ + { + eventName: 'Secrets Pulled', + user: 'matsiiako@gmail.com', + source: 'CLI', + time: new Date() + }, + { + eventName: 'Secrets Pushed', + user: 'matsiiako@gmail.com', + source: 'Web', + time: new Date() + } +]; + +/** + * This tab is called Home because in the future it will include some company news, + * updates, roadmap, relavant blogs, etc. Currently it only has the setup instruction + * for the new users + */ +export default function Activity() { + const router = useRouter(); + const [hasUserClickedSlack, setHasUserClickedSlack] = useState(false); + const [hasUserClickedIntro, setHasUserClickedIntro] = useState(false); + const [hasUserStarred, setHasUserStarred] = useState(false); + const [hasUserPushedSecrets, setHasUserPushedSecrets] = useState(false); + const [usersInOrg, setUsersInOrg] = useState(false); + + useEffect(() => { + onboardingCheck({ + setHasUserClickedIntro, + setHasUserClickedSlack, + setHasUserPushedSecrets, + setHasUserStarred, + setUsersInOrg + }); + }, []); + + return ( +
+ +
+
+

Activity Logs

+
+

+ Manage your integrations of Infisical with third-party services. +

+
+ +
+ ); +} + +Activity.requireAuth = true; From 9218d2a6535a617157742d183be687e07fbb6d1e Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Sat, 17 Dec 2022 08:48:10 -0500 Subject: [PATCH 02/46] Fixed the padding issue in the login page --- frontend/pages/login.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/pages/login.tsx b/frontend/pages/login.tsx index 63dae65a7..42fb755c9 100644 --- a/frontend/pages/login.tsx +++ b/frontend/pages/login.tsx @@ -101,7 +101,7 @@ export default function Login() { autoComplete="current-password" id="current-password" /> -
+
Forgot password?
From 2e84b7e3549b4a56255b7fd9945cf72c6a26f10a Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sat, 17 Dec 2022 15:10:30 -0500 Subject: [PATCH 03/46] Initial schema ideas for logging --- backend/src/controllers/index.ts | 4 ++- backend/src/controllers/logController.ts | 30 ++++++++++++++++ backend/src/index.ts | 4 ++- backend/src/models/index.ts | 5 ++- backend/src/models/log.ts | 46 ++++++++++++++++++++++++ backend/src/routes/index.ts | 4 ++- backend/src/routes/log.ts | 17 +++++++++ 7 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 backend/src/controllers/logController.ts create mode 100644 backend/src/models/log.ts create mode 100644 backend/src/routes/log.ts diff --git a/backend/src/controllers/index.ts b/backend/src/controllers/index.ts index 2d3debfb5..e52d021b1 100644 --- a/backend/src/controllers/index.ts +++ b/backend/src/controllers/index.ts @@ -13,6 +13,7 @@ import * as stripeController from './stripeController'; import * as userActionController from './userActionController'; import * as userController from './userController'; import * as workspaceController from './workspaceController'; +import * as logController from './logController'; export { authController, @@ -29,5 +30,6 @@ export { stripeController, userActionController, userController, - workspaceController + workspaceController, + logController }; diff --git a/backend/src/controllers/logController.ts b/backend/src/controllers/logController.ts new file mode 100644 index 000000000..3e1d7d535 --- /dev/null +++ b/backend/src/controllers/logController.ts @@ -0,0 +1,30 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { + Log +} from '../models'; + + +export const getLogs = async (req: Request, res: Response) => { + // get logs + + console.log('getLogs'); + let logs; + try { + const { workspaceId } = req.params; + + logs = await Log.find({ + workspace: workspaceId + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get audit logs' + }); + } + + return res.status(200).send({ + logs + }); +} \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 28e27cc9a..fd4f867af 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -38,7 +38,8 @@ import { password as passwordRouter, stripe as stripeRouter, integration as integrationRouter, - integrationAuth as integrationAuthRouter + integrationAuth as integrationAuthRouter, + log as logRouter } from './routes'; const connectWithRetry = () => { @@ -92,6 +93,7 @@ app.use('/api/v1/password', passwordRouter); app.use('/api/v1/stripe', stripeRouter); app.use('/api/v1/integration', integrationRouter); app.use('/api/v1/integration-auth', integrationAuthRouter); +app.use('/api/v1/log', logRouter); const server = http.createServer(app); diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 9b07f6766..53f5a395a 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -12,6 +12,7 @@ import Token, { IToken } from './token'; import User, { IUser } from './user'; import UserAction, { IUserAction } from './userAction'; import Workspace, { IWorkspace } from './workspace'; +import Log, { ILog } from './log'; export { BackupPrivateKey, @@ -41,5 +42,7 @@ export { UserAction, IUserAction, Workspace, - IWorkspace + IWorkspace, + Log, + ILog }; diff --git a/backend/src/models/log.ts b/backend/src/models/log.ts new file mode 100644 index 000000000..7078ae52f --- /dev/null +++ b/backend/src/models/log.ts @@ -0,0 +1,46 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface ILog { + _id: Types.ObjectId; + user: Types.ObjectId; + workspace: Types.ObjectId; + event: string; + source: string; + ipAddress: string; +} + +// TODO: need a way to store payload info for each +// log + +// which secret is being ref etc. + +const logSchema = new Schema( + { + user: { + type: Schema.Types.ObjectId, + ref: 'User' + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace' + }, + event: { + type: String, + required: true + }, + source: { // should this just be a payload attr? + type: String, + required: true + }, + ipAddress: { // store in bytes? + type: String, + required: true + } + }, { + timestamps: true + } +); + +const Log = model('Log', logSchema); + +export default Log; \ No newline at end of file diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index cf015abfb..97dc72c83 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -14,6 +14,7 @@ import password from './password'; import stripe from './stripe'; import integration from './integration'; import integrationAuth from './integrationAuth'; +import log from './log'; export { signup, @@ -31,5 +32,6 @@ export { password, stripe, integration, - integrationAuth + integrationAuth, + log }; diff --git a/backend/src/routes/log.ts b/backend/src/routes/log.ts new file mode 100644 index 000000000..43d91d7ac --- /dev/null +++ b/backend/src/routes/log.ts @@ -0,0 +1,17 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, + validateRequest +} from '../middleware'; +import { logController } from '../controllers'; + +// TODO: workspaceId validation +router.get( + '/:workspaceId', + requireAuth, + validateRequest, + logController.getLogs +); + +export default router; \ No newline at end of file From fae27a0b6e9645a7ff02ef4450b434c04688e531 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Sat, 17 Dec 2022 20:21:25 -0500 Subject: [PATCH 04/46] Changed text for the activity page --- frontend/pages/activity/[id].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/pages/activity/[id].tsx b/frontend/pages/activity/[id].tsx index 5ffba0328..3dc55799f 100644 --- a/frontend/pages/activity/[id].tsx +++ b/frontend/pages/activity/[id].tsx @@ -51,7 +51,7 @@ export default function Activity() {

Activity Logs

- Manage your integrations of Infisical with third-party services. + Event history limited to the last 12 months.

From 9d41f753f4b7f84aad6c5fccf655d07c9786d5b2 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Sat, 17 Dec 2022 21:43:13 -0500 Subject: [PATCH 05/46] Added Intercom to Docs --- docs/mint.json | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/mint.json b/docs/mint.json index 7343622db..2ac025ca6 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -21,7 +21,9 @@ "to": "#F8B7BD" } }, - "topbarLinks": [{ "name": "Log In", "url": "https://app.infisical.com/login" }], + "topbarLinks": [ + { "name": "Log In", "url": "https://app.infisical.com/login" } + ], "topbarCtaButton": { "name": "Start for Free", "url": "https://app.infisical.com/signup" @@ -113,12 +115,10 @@ "pages": ["self-hosting/configuration/envars"] } ] - }, + }, { "group": "Integrations", - "pages": [ - "integrations/overview" - ] + "pages": ["integrations/overview"] }, { "group": "Platforms", @@ -138,9 +138,7 @@ }, { "group": "CI/CD", - "pages": [ - "integrations/cicd/circleci" - ] + "pages": ["integrations/cicd/circleci"] }, { "group": "Frameworks", @@ -179,5 +177,8 @@ ] } ], - "backgroundImage": "/images/background.png" + "backgroundImage": "/images/background.png", + "integrations": { + "intercom": "hsg644ru" + } } From 648e3e3bbf7c1f0e8fc0bd6b7cdddd28837ab082 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 18 Dec 2022 17:19:09 -0500 Subject: [PATCH 06/46] Continue developing log schema --- backend/src/models/log.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/backend/src/models/log.ts b/backend/src/models/log.ts index 7078ae52f..e3716ab84 100644 --- a/backend/src/models/log.ts +++ b/backend/src/models/log.ts @@ -2,11 +2,11 @@ import { Schema, model, Types } from 'mongoose'; export interface ILog { _id: Types.ObjectId; - user: Types.ObjectId; + user?: Types.ObjectId; workspace: Types.ObjectId; event: string; source: string; - ipAddress: string; + ipAddress?: string; } // TODO: need a way to store payload info for each @@ -14,6 +14,8 @@ export interface ILog { // which secret is being ref etc. +// user logged in + const logSchema = new Schema( { user: { @@ -24,17 +26,25 @@ const logSchema = new Schema( type: Schema.Types.ObjectId, ref: 'Workspace' }, - event: { + event: { // push, pull type: String, required: true }, - source: { // should this just be a payload attr? + payload: { // should this just be a payload attr? + numberOfSecrets: { + type: Number + }, + environment: { + type: String + } + }, + channel: { type: String, + enum: ['web', 'cli', 'auto'], required: true }, ipAddress: { // store in bytes? - type: String, - required: true + type: String } }, { timestamps: true From 7d280d4e30e258abe0efbdd9f52c3a6e051bfe78 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Sun, 18 Dec 2022 21:53:21 -0500 Subject: [PATCH 07/46] Added event filter for logs --- frontend/components/basic/EventFilter.tsx | 112 ++++++++++++++++++ frontend/components/basic/Layout.tsx | 6 +- frontend/components/basic/Listbox.tsx | 30 ++--- .../components/basic/table/ActivityTable.tsx | 9 +- frontend/pages/activity/[id].tsx | 101 +++++++++++++++- 5 files changed, 233 insertions(+), 25 deletions(-) create mode 100644 frontend/components/basic/EventFilter.tsx diff --git a/frontend/components/basic/EventFilter.tsx b/frontend/components/basic/EventFilter.tsx new file mode 100644 index 000000000..5a9506f0e --- /dev/null +++ b/frontend/components/basic/EventFilter.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Fragment } from 'react'; +import { + faAngleDown, + faCheck, + faDownload, + faPlus, + faUpload, + faX +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Listbox, Transition } from '@headlessui/react'; + +import guidGenerator from '../utilities/randomId'; +import Button from './buttons/Button'; + +interface ListBoxProps { + selected: string; + select: (event: string) => void; + data: string[]; + text?: string; + buttonAction?: () => void; + isFull?: boolean; +} + +const eventOptions = [ + { + name: 'Secrets Pushed', + icon: faUpload + }, + { + name: 'Secrets Pulled', + icon: faDownload + } +]; + +/** + * This is the component that we use for drop down lists. + * @param {object} obj + * @param {string} obj.selected - the item that is currently selected + * @param {function} obj.select - what happends if you select the item inside a list + * @param {string[]} obj.data - all the options available + * @param {string} obj.text - the text that shows us in front of the select option + * @param {function} obj.buttonAction - if there is a button at the bottom of the list, this is the action that happens when you click the button + * @param {string} obj.width - button width + * @returns + */ +export default function EventFilter({ + selected, + select, + data, + text, + buttonAction, + isFull +}: ListBoxProps): JSX.Element { + return ( + +
+ + {selected != '' ? ( +

{selected}

+ ) : ( +

Select an event

+ )} + {selected != '' ? ( + select('')} + /> + ) : ( + + )} +
+ + + {eventOptions.map((event, id) => { + return ( + + {({ selected }) => ( + <> + + {' '} + {event.name} + + + )} + {/* {event.name} */} + + ); + })} + + +
+
+ ); +} diff --git a/frontend/components/basic/Layout.tsx b/frontend/components/basic/Layout.tsx index 91f257ff9..bf3b8bf51 100644 --- a/frontend/components/basic/Layout.tsx +++ b/frontend/components/basic/Layout.tsx @@ -4,12 +4,12 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { - faBook, faBookOpen, faGear, faKey, faMobile, faPlug, + faTimeline, faUser } from '@fortawesome/free-solid-svg-icons'; import { faPlus } from '@fortawesome/free-solid-svg-icons'; @@ -153,8 +153,8 @@ export default function Layout({ children }: LayoutProps) { }, { href: '/activity/' + workspaceMapping[workspaceSelected as any], - title: 'Activity', - emoji: + title: 'Activity Logs', + emoji: }, { href: '/settings/project/' + workspaceMapping[workspaceSelected as any], diff --git a/frontend/components/basic/Listbox.tsx b/frontend/components/basic/Listbox.tsx index aa8c41f85..e87bab208 100644 --- a/frontend/components/basic/Listbox.tsx +++ b/frontend/components/basic/Listbox.tsx @@ -1,19 +1,19 @@ -import React from "react"; -import { Fragment } from "react"; +import React from 'react'; +import { Fragment } from 'react'; import { faAngleDown, faCheck, - faPlus, -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Listbox, Transition } from "@headlessui/react"; + faPlus +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Listbox, Transition } from '@headlessui/react'; interface ListBoxProps { selected: string; onChange: () => void; data: string[]; - text: string; - buttonAction: () => void; + text?: string; + buttonAction?: () => void; isFull?: boolean; } @@ -34,20 +34,20 @@ export default function ListBox({ data, text, buttonAction, - isFull, + isFull }: ListBoxProps): JSX.Element { return (
{text} - {" "} + {' '} {selected}
@@ -70,11 +70,11 @@ export default function ListBox({ key={personIdx} className={({ active, selected }) => `my-0.5 relative cursor-default select-none py-2 pl-10 pr-4 rounded-md ${ - selected ? "bg-white/10 text-gray-400 font-bold" : "" + selected ? 'bg-white/10 text-gray-400 font-bold' : '' } ${ active && !selected - ? "bg-white/5 text-mineshaft-200 cursor-pointer" - : "text-gray-400" + ? 'bg-white/5 text-mineshaft-200 cursor-pointer' + : 'text-gray-400' } ` } value={person} @@ -83,7 +83,7 @@ export default function ListBox({ <> {person} diff --git a/frontend/components/basic/table/ActivityTable.tsx b/frontend/components/basic/table/ActivityTable.tsx index fe98277e7..0f5f3eb6b 100644 --- a/frontend/components/basic/table/ActivityTable.tsx +++ b/frontend/components/basic/table/ActivityTable.tsx @@ -49,17 +49,14 @@ const ActivityLogsRow = ({ row }: { row: ActivityTableProps }): JSX.Element => { const [payloadOpened, setPayloadOpened] = useState(false); return ( <> - +
setPayloadOpened(!payloadOpened)} - className="border-mineshaft-700 border-t text-gray-300 flex items-center" + className="border-mineshaft-700 border-t text-gray-300 flex items-center cursor-pointer" > diff --git a/frontend/pages/activity/[id].tsx b/frontend/pages/activity/[id].tsx index 3dc55799f..9b51d7f74 100644 --- a/frontend/pages/activity/[id].tsx +++ b/frontend/pages/activity/[id].tsx @@ -1,11 +1,96 @@ import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; +import EventFilter from '~/components/basic/EventFilter'; import ActivityTable from '~/components/basic/table/ActivityTable'; import NavHeader from '~/components/navigation/NavHeader'; import onboardingCheck from '~/components/utilities/checks/OnboardingCheck'; const data = [ + { + eventName: 'Secrets Pulled', + user: 'matsiiako@gmail.com', + source: 'CLI', + time: new Date() + }, + { + eventName: 'Secrets Pushed', + user: 'matsiiako@gmail.com', + source: 'Web', + time: new Date() + }, + { + eventName: 'Secrets Pulled', + user: 'matsiiako@gmail.com', + source: 'CLI', + time: new Date() + }, + { + eventName: 'Secrets Pushed', + user: 'matsiiako@gmail.com', + source: 'Web', + time: new Date() + }, + { + eventName: 'Secrets Pulled', + user: 'matsiiako@gmail.com', + source: 'CLI', + time: new Date() + }, + { + eventName: 'Secrets Pushed', + user: 'matsiiako@gmail.com', + source: 'Web', + time: new Date() + }, + { + eventName: 'Secrets Pulled', + user: 'matsiiako@gmail.com', + source: 'CLI', + time: new Date() + }, + { + eventName: 'Secrets Pushed', + user: 'matsiiako@gmail.com', + source: 'Web', + time: new Date() + }, + { + eventName: 'Secrets Pulled', + user: 'matsiiako@gmail.com', + source: 'CLI', + time: new Date() + }, + { + eventName: 'Secrets Pushed', + user: 'matsiiako@gmail.com', + source: 'Web', + time: new Date() + }, + { + eventName: 'Secrets Pulled', + user: 'matsiiako@gmail.com', + source: 'CLI', + time: new Date() + }, + { + eventName: 'Secrets Pushed', + user: 'matsiiako@gmail.com', + source: 'Web', + time: new Date() + }, + { + eventName: 'Secrets Pulled', + user: 'matsiiako@gmail.com', + source: 'CLI', + time: new Date() + }, + { + eventName: 'Secrets Pushed', + user: 'matsiiako@gmail.com', + source: 'Web', + time: new Date() + }, { eventName: 'Secrets Pulled', user: 'matsiiako@gmail.com', @@ -32,6 +117,7 @@ export default function Activity() { const [hasUserStarred, setHasUserStarred] = useState(false); const [hasUserPushedSecrets, setHasUserPushedSecrets] = useState(false); const [usersInOrg, setUsersInOrg] = useState(false); + const [eventChosen, setEventChosen] = useState(''); useEffect(() => { onboardingCheck({ @@ -54,7 +140,20 @@ export default function Activity() { Event history limited to the last 12 months.

- + {/* Licence Required +
+ +
*/} + + eventChosen != '' ? event.eventName == eventChosen : event + )} + />
); } From 009f9c684217a0b8384d22149cb35575677bd6d1 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 21 Dec 2022 16:27:04 -0500 Subject: [PATCH 08/46] Continue developing activity logs backend --- backend/src/events/secret.ts | 27 ++++++++++++-- backend/src/helpers/log.ts | 25 +++++++++++++ backend/src/logs/index.ts | 0 backend/src/logs/secret.ts | 0 backend/src/models/log.ts | 65 +++++++++++++++++++++++++++++----- backend/src/models/logGroup.ts | 25 +++++++++++++ 6 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 backend/src/helpers/log.ts create mode 100644 backend/src/logs/index.ts create mode 100644 backend/src/logs/secret.ts create mode 100644 backend/src/models/logGroup.ts diff --git a/backend/src/events/secret.ts b/backend/src/events/secret.ts index 8bb3a86c3..479255a31 100644 --- a/backend/src/events/secret.ts +++ b/backend/src/events/secret.ts @@ -1,4 +1,7 @@ -import { EVENT_PUSH_SECRETS } from '../variables'; +import { + EVENT_PUSH_SECRETS, + EVENT_PULL_SECRETS +} from '../variables'; interface PushSecret { ciphertextKey: string; @@ -19,7 +22,7 @@ interface PushSecret { * @returns */ const eventPushSecrets = ({ - workspaceId, + workspaceId }: { workspaceId: string; }) => { @@ -32,6 +35,26 @@ const eventPushSecrets = ({ }); } +/** + * Return event for pulling secrets + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace to pull secrets from + * @returns + */ +const eventPullSecrets = ({ + workspaceId, +}: { + workspaceId: string; +}) => { + return ({ + name: EVENT_PULL_SECRETS, + workspaceId, + payload: { + + } + }); +} + export { eventPushSecrets } diff --git a/backend/src/helpers/log.ts b/backend/src/helpers/log.ts new file mode 100644 index 000000000..2a2f1530b --- /dev/null +++ b/backend/src/helpers/log.ts @@ -0,0 +1,25 @@ +import { Log, ILog } from '../models'; +import * as Sentry from '@sentry/node'; +import { + EVENT_PUSH_SECRETS, + EVENT_PULL_SECRETS +} from '../variables'; + + +const handleLogHelper = async ({ + log +}: { + log: ILog +}) => { + try { + switch (log.event) { + case EVENT_PULL_SECRETS: + // TODO + break; + } + + } catch (err){ + Sentry.setUser(null); + Sentry.captureException(err); + } +} \ No newline at end of file diff --git a/backend/src/logs/index.ts b/backend/src/logs/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/logs/secret.ts b/backend/src/logs/secret.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/models/log.ts b/backend/src/models/log.ts index 81dd9fb52..e232d68a7 100644 --- a/backend/src/models/log.ts +++ b/backend/src/models/log.ts @@ -5,6 +5,7 @@ export interface ILog { user?: Types.ObjectId; workspace: Types.ObjectId; event: string; + groupId: string; payload: { numberofSecrets?: number; environment?: string; @@ -13,6 +14,52 @@ export interface ILog { ipAddress?: string; } +// log group consists of logs (each log is associated with 1 event) +// scenario: + +// do we in the future record old and new values for secrets? (when you log update secret, +// do you want to know what the old secret value was changed to?) + +// Option 1: + +// action 1: pushed secrets (top-level event) +// - log 1 (groupId: ABC): modified 10 secrets (sub-level event) +// ---- array of secret ids that were modified +// - log 2 (groupId: ABC): deleted 5 secrets +// ---- array of secret ids that were deleted +// - log 3 (groupId: ABC): created 10 secrets +// ---- array of secret ids that were created + +// action 2: pull secrets +// - log 4 (groupId: DEF): read 20 secrets +// ---- array of secret ids that were read + +// Option 2 (many logs): + +// action 1: pushed secrets (top-level event) +// - log 1 (groupId: ABC): modified secret abc +// - log 2 (groupId: ABC): modified secret def +// - log 3 (groupId: ABC): modified secret ghi +// - log 4 (groupId: ABC): created secret jkl +// - log 5 (groupId: ABC): created secret mno +// - log 6 (groupId: ABC): deleted secret pqr + +// action 2: pull secrets (pulling 100 secrets = 100 logs; 10 times per day, 5 people => 5000 logs) +// - log 7 (groupId: DEF): read secret abc +// - log 8 (groupId: DEF): read secret def +// - log 9 (groupId: DEF): read secret ghi +// - log 10 (groupId: DEF): read secret jkl +// - log 11 (groupId: DEF): read secret mno + +// logGroup +// ---- log (query for log groups by person and by secret etc.) + +/** + * Action: save secrets + * - + * + */ + const logSchema = new Schema( { user: { @@ -23,17 +70,19 @@ const logSchema = new Schema( type: Schema.Types.ObjectId, ref: 'Workspace' }, - event: { // push, pull + event: { // CRUD secrets type: String, required: true }, - payload: { - numberOfSecrets: { - type: Number - }, - environment: { - type: String - } + groupId: { + type: String, + required: true, + }, + payload: { + secrets: [{ + type: Schema.Types.ObjectId, + ref: 'Secret' + }] }, channel: { type: String, diff --git a/backend/src/models/logGroup.ts b/backend/src/models/logGroup.ts new file mode 100644 index 000000000..29527f690 --- /dev/null +++ b/backend/src/models/logGroup.ts @@ -0,0 +1,25 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface ILogGroup { + workspace: Types.ObjectId, + logs: [Types.ObjectId] +} + +const logGroupSchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace' + }, + logs: [{ + type: Schema.Types.ObjectId, + ref: 'Log' + }] + }, { + timestamps: true + } +); + +const LogGroup = model('LogGroup', logGroupSchema); + +export default LogGroup; \ No newline at end of file From 9497a26eb2064b453da60f96870f9bb0aac0f5c5 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 27 Dec 2022 12:12:39 -0500 Subject: [PATCH 09/46] Add v1 audit log backend models and wiring to push secrets --- backend/src/app.ts | 4 +- backend/src/controllers/logController.ts | 30 -- backend/src/controllers/v1/index.ts | 4 +- .../src/controllers/v2/workspaceController.ts | 4 +- .../ee/controllers/v1/workspaceController.ts | 34 ++- backend/src/ee/helpers/log.ts | 40 +++ backend/src/ee/models/action.ts | 40 +++ backend/src/ee/models/index.ts | 12 +- backend/src/ee/models/log.ts | 41 +++ backend/src/ee/routes/v1/index.ts | 4 +- backend/src/ee/routes/v1/log.ts | 4 + backend/src/ee/routes/v1/workspace.ts | 14 + backend/src/ee/services/EELogService.ts | 47 +++ backend/src/ee/services/index.ts | 4 +- backend/src/ee/variables.ts | 0 backend/src/helpers/secret.ts | 278 +++++++++++++----- backend/src/logs/index.ts | 0 backend/src/logs/secret.ts | 0 backend/src/models/index.ts | 5 +- backend/src/models/log.ts | 102 ------- backend/src/models/logGroup.ts | 25 -- backend/src/routes/log.ts | 17 -- backend/src/routes/v1/index.ts | 4 +- backend/src/routes/v1/userAction.ts | 1 + backend/src/variables/action.ts | 9 + backend/src/variables/index.ts | 8 + 26 files changed, 459 insertions(+), 272 deletions(-) delete mode 100644 backend/src/controllers/logController.ts create mode 100644 backend/src/ee/helpers/log.ts create mode 100644 backend/src/ee/models/action.ts create mode 100644 backend/src/ee/models/log.ts create mode 100644 backend/src/ee/routes/v1/log.ts create mode 100644 backend/src/ee/services/EELogService.ts delete mode 100644 backend/src/ee/variables.ts delete mode 100644 backend/src/logs/index.ts delete mode 100644 backend/src/logs/secret.ts delete mode 100644 backend/src/models/log.ts delete mode 100644 backend/src/models/logGroup.ts delete mode 100644 backend/src/routes/log.ts create mode 100644 backend/src/variables/action.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 8320a7d76..4e8e23fc0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -13,7 +13,8 @@ import { apiLimiter } from './helpers/rateLimiter'; import { workspace as eeWorkspaceRouter, - secret as eeSecretRouter + secret as eeSecretRouter, + log as eeLogRouter, } from './ee/routes/v1'; import { signup as v1SignupRouter, @@ -69,6 +70,7 @@ if (NODE_ENV === 'production') { // (EE) routes app.use('/api/v1/secret', eeSecretRouter); app.use('/api/v1/workspace', eeWorkspaceRouter); +app.use('/api/v1/log', eeLogRouter); // v1 routes app.use('/api/v1/signup', v1SignupRouter); diff --git a/backend/src/controllers/logController.ts b/backend/src/controllers/logController.ts deleted file mode 100644 index 3e1d7d535..000000000 --- a/backend/src/controllers/logController.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Request, Response } from 'express'; -import * as Sentry from '@sentry/node'; -import { - Log -} from '../models'; - - -export const getLogs = async (req: Request, res: Response) => { - // get logs - - console.log('getLogs'); - let logs; - try { - const { workspaceId } = req.params; - - logs = await Log.find({ - workspace: workspaceId - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get audit logs' - }); - } - - return res.status(200).send({ - logs - }); -} \ No newline at end of file diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index a8756b4f3..1da61835f 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -14,7 +14,6 @@ import * as stripeController from './stripeController'; import * as userActionController from './userActionController'; import * as userController from './userController'; import * as workspaceController from './workspaceController'; -import * as logController from './logController'; export { authController, @@ -32,6 +31,5 @@ export { stripeController, userActionController, userController, - workspaceController, - logController + workspaceController }; diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 86693b6c4..b9aa6406f 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -389,7 +389,9 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { userId: req.user._id, workspaceId, environment, - secrets + secrets, + channel: channel ? channel : 'cli', + ipAddress: req.ip }); await pushKeys({ diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 8b7ba422e..1c9a71bc8 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -1,6 +1,9 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; -import { SecretSnapshot } from '../../models'; +import { + SecretSnapshot, + Log +} from '../../models'; /** * Return secret snapshots for workspace with id [workspaceId] @@ -32,4 +35,33 @@ import { SecretSnapshot } from '../../models'; return res.status(200).send({ secretSnapshots }); +} + +export const getWorkspaceLogs = async (req: Request, res: Response) => { + let logs + try { + const { workspaceId } = req.params; + + const offset: number = parseInt(req.query.offset as string); + const limit: number = parseInt(req.query.limit as string); + const filters: any = req.query.filters || {}; + + filters.workspace = workspaceId; + + logs = await Log.find(filters) + .skip(offset) + .limit(limit) + .populate('actions'); + + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get workspace logs' + }); + } + + return res.status(200).send({ + logs + }); } \ No newline at end of file diff --git a/backend/src/ee/helpers/log.ts b/backend/src/ee/helpers/log.ts new file mode 100644 index 000000000..a075cc91f --- /dev/null +++ b/backend/src/ee/helpers/log.ts @@ -0,0 +1,40 @@ +import * as Sentry from '@sentry/node'; +import { + Log, + IAction +} from '../models'; + +const createLogHelper = async ({ + userId, + workspaceId, + actions, + channel, + ipAddress +}: { + userId: string; + workspaceId: string; + actions: IAction[]; + channel: string; + ipAddress: string; +}) => { + let log; + try { + log = await new Log({ + user: userId, + workspace: workspaceId, + actions, + channel, + ipAddress + }).save(); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to create log'); + } + + return log; +} + +export { + createLogHelper +} \ No newline at end of file diff --git a/backend/src/ee/models/action.ts b/backend/src/ee/models/action.ts new file mode 100644 index 000000000..8ffdfe4bb --- /dev/null +++ b/backend/src/ee/models/action.ts @@ -0,0 +1,40 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface IAction { + name: string; + user?: Types.ObjectId, + workspace?: Types.ObjectId, + payload: { + secretVersions?: Types.ObjectId[] + } +} + +const actionSchema = new Schema( + { + name: { + type: String, + required: true + }, + user: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace' + }, + payload: { + secretVersions: [{ + type: Schema.Types.ObjectId, + ref: 'SecretVersion' + }] + } + }, { + timestamps: true + } +); + +const Action = model('Action', actionSchema); + +export default Action; \ No newline at end of file diff --git a/backend/src/ee/models/index.ts b/backend/src/ee/models/index.ts index 35d41c19a..a6cee725e 100644 --- a/backend/src/ee/models/index.ts +++ b/backend/src/ee/models/index.ts @@ -1,9 +1,15 @@ -import SecretSnapshot, { ISecretSnapshot } from "./secretSnapshot"; -import SecretVersion, { ISecretVersion } from "./secretVersion"; +import SecretSnapshot, { ISecretSnapshot } from './secretSnapshot'; +import SecretVersion, { ISecretVersion } from './secretVersion'; +import Log, { ILog } from './log'; +import Action, { IAction } from './action'; export { SecretSnapshot, ISecretSnapshot, SecretVersion, - ISecretVersion + ISecretVersion, + Log, + ILog, + Action, + IAction } \ No newline at end of file diff --git a/backend/src/ee/models/log.ts b/backend/src/ee/models/log.ts new file mode 100644 index 000000000..abfadb223 --- /dev/null +++ b/backend/src/ee/models/log.ts @@ -0,0 +1,41 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface ILog { + _id: Types.ObjectId; + user?: Types.ObjectId; + workspace?: Types.ObjectId; + actions: Types.ObjectId[]; + channel: string; + ipAddress?: string; +} + +const logSchema = new Schema( + { + user: { + type: Schema.Types.ObjectId, + ref: 'User' + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace' + }, + actions: [{ + type: Schema.Types.ObjectId, + ref: 'Action' + }], + channel: { + type: String, + enum: ['web', 'cli', 'auto'], + required: true + }, + ipAddress: { + type: String + } + }, { + timestamps: true + } +); + +const Log = model('Log', logSchema); + +export default Log; \ No newline at end of file diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 960665f4a..810a050a6 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,7 +1,9 @@ import secret from './secret'; import workspace from './workspace'; +import log from './log'; export { secret, - workspace + workspace, + log } \ No newline at end of file diff --git a/backend/src/ee/routes/v1/log.ts b/backend/src/ee/routes/v1/log.ts new file mode 100644 index 000000000..d90cb867c --- /dev/null +++ b/backend/src/ee/routes/v1/log.ts @@ -0,0 +1,4 @@ +import express from 'express'; +const router = express.Router(); + +export default router; \ No newline at end of file diff --git a/backend/src/ee/routes/v1/workspace.ts b/backend/src/ee/routes/v1/workspace.ts index 6756269e5..5deb7dd85 100644 --- a/backend/src/ee/routes/v1/workspace.ts +++ b/backend/src/ee/routes/v1/workspace.ts @@ -23,5 +23,19 @@ router.get( workspaceController.getWorkspaceSecretSnapshots ); +router.get( + '/:workspaceId/logs', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [GRANTED] + }), + param('workspaceId').exists().trim(), + query('offset').exists().isInt(), + query('limit').exists().isInt(), + query('filters').exists(), + validateRequest, + workspaceController.getWorkspaceLogs +); export default router; \ No newline at end of file diff --git a/backend/src/ee/services/EELogService.ts b/backend/src/ee/services/EELogService.ts new file mode 100644 index 000000000..7e6ca4acf --- /dev/null +++ b/backend/src/ee/services/EELogService.ts @@ -0,0 +1,47 @@ +import { + Action, + IAction +} from '../models'; +import { + createLogHelper +} from '../helpers/log'; +import EELicenseService from './EELicenseService'; + +/** + * Class to handle Enterprise Edition log actions + */ +class EELogService { + /** + * Create an (audit) log + * @param {Object} obj + * @param {String} obj.userId - id of user associated with the log + * @param {String} obj.workspaceId - id of workspace associated with the 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.ipAddress - ip address associated with the log + */ + static async createLog({ + userId, + workspaceId, + actions, + channel, + ipAddress + }: { + userId: string; + workspaceId: string; + actions: IAction[]; + channel: string; + ipAddress: string; + }) { + if (!EELicenseService.isLicenseValid) return; + return await createLogHelper({ + userId, + workspaceId, + actions, + channel, + ipAddress + }) + } +} + +export default EELogService; \ No newline at end of file diff --git a/backend/src/ee/services/index.ts b/backend/src/ee/services/index.ts index 3cec256bb..b3544bcff 100644 --- a/backend/src/ee/services/index.ts +++ b/backend/src/ee/services/index.ts @@ -1,7 +1,9 @@ import EELicenseService from "./EELicenseService"; import EESecretService from "./EESecretService"; +import EELogService from "./EELogService"; export { EELicenseService, - EESecretService + EESecretService, + EELogService } \ No newline at end of file diff --git a/backend/src/ee/variables.ts b/backend/src/ee/variables.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index f055971ae..79d99960f 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -1,19 +1,29 @@ import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import { Secret, ISecret, } from '../models'; import { - EESecretService + EESecretService, + EELogService } from '../ee/services'; import { - SecretVersion + SecretVersion, + Action, + IAction } from '../ee/models'; import { takeSecretSnapshotHelper } from '../ee/helpers/secret'; import { decryptSymmetric } from '../utils/crypto'; -import { SECRET_SHARED, SECRET_PERSONAL } from '../variables'; +import { + SECRET_SHARED, + SECRET_PERSONAL, + ACTION_ADD_SECRETS, + ACTION_UPDATE_SECRETS, + ACTION_DELETE_SECRETS +} from '../variables'; interface V1PushSecret { ciphertextKey: string; @@ -284,20 +294,28 @@ const v1PushSecrets = async ({ * @param {String} obj.workspaceId - id of workspace to push to * @param {String} obj.environment - environment for secrets * @param {Object[]} obj.secrets - secrets to push + * @param {String} obj.channel - channel (web/cli/auto) + * @param {String} obj.ipAddress - ip address of request to push secrets */ const v2PushSecrets = async ({ userId, workspaceId, environment, - secrets + secrets, + channel, + ipAddress }: { userId: string; workspaceId: string; environment: string; secrets: V2PushSecret[]; + channel: string; + ipAddress: string; }): Promise => { // TODO: clean up function and fix up types try { + const actions: IAction[] = []; + // construct useful data structures const oldSecrets = await pullSecrets({ userId, @@ -327,7 +345,37 @@ const v1PushSecrets = async ({ secret: { $in: toDelete } }, { isDeleted: true + }, { + new: true }); + + // add audit log for deleted secrets + 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 @@ -348,88 +396,119 @@ const v1PushSecrets = async ({ return false; }); - const operations = toUpdate - .map((s) => { - const { - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretValueHash, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag, - secretCommentHash, - } = newSecretsObj[`${s.type}-${s.secretKeyHash}`]; + if (toUpdate.length > 0) { + const operations = toUpdate + .map((s) => { + const { + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentHash, + } = newSecretsObj[`${s.type}-${s.secretKeyHash}`]; - const update: Update = { - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretValueHash, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag, - secretCommentHash, - } - - if (!s.version) { - // case: (legacy) secret was not versioned - update.version = 1; - } else { - update['$inc'] = { - version: 1 + const update: Update = { + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentHash, } - } - if (s.type === SECRET_PERSONAL) { - // attach user associated with the personal secret - update['user'] = userId; - } - - return { - updateOne: { - filter: { - _id: oldSecretsObj[`${s.type}-${s.secretKeyHash}`]._id - }, - update + if (!s.version) { + // case: (legacy) secret was not versioned + update.version = 1; + } else { + update['$inc'] = { + version: 1 + } } - }; + + if (s.type === SECRET_PERSONAL) { + // attach user associated with the personal secret + update['user'] = userId; + } + + return { + updateOne: { + filter: { + _id: oldSecretsObj[`${s.type}-${s.secretKeyHash}`]._id + }, + update + } + }; + }); + await Secret.bulkWrite(operations as any); + + // (EE) add secret versions for updated secrets + await EESecretService.addSecretVersions({ + secretVersions: toUpdate.map((s) => { + const { + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + secretCommentHash, + } = newSecretsObj[`${s.type}-${s.secretKeyHash}`]; + + return ({ + secret: s._id, + version: s.version ? s.version + 1 : 1, + isDeleted: false, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + }) + }) }); - await Secret.bulkWrite(operations as any); - - // (EE) add secret versions for updated secrets - await EESecretService.addSecretVersions({ - secretVersions: toUpdate.map((s) => { - const { - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretKeyHash, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretValueHash, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag, - secretCommentHash, - } = newSecretsObj[`${s.type}-${s.secretKeyHash}`]; - return ({ - secret: s._id, - version: s.version ? s.version + 1 : 1, - isDeleted: false, - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretKeyHash, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretValueHash - }) - }) - }); + // add audit log for updated secrets + 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, + user: new Types.ObjectId(userId), + workspace: new Types.ObjectId(workspaceId), + payload: { + secretVersions: updatedLatestSecretVersions + } + }).save(); + + actions.push(updateAction); + } // handle adding new secrets const toAdd = secrets.filter((s) => !(`${s.type}-${s.secretKeyHash}` in oldSecretsObj)); @@ -504,12 +583,51 @@ const v1PushSecrets = async ({ 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({ + name: ACTION_ADD_SECRETS, + user: new Types.ObjectId(userId), + workspace: new Types.ObjectId(workspaceId), + payload: { + secretVersions: newLatestSecretVersions + } + }).save(); + + actions.push(addAction); } // (EE) take a secret snapshot await EESecretService.takeSecretSnapshot({ workspaceId }) + + if (actions.length > 0) { + await EELogService.createLog({ + userId, + workspaceId, + actions, + channel, + ipAddress + }); + } } catch (err) { Sentry.setUser(null); Sentry.captureException(err); diff --git a/backend/src/logs/index.ts b/backend/src/logs/index.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/src/logs/secret.ts b/backend/src/logs/secret.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 8d125499c..78c38060b 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -14,7 +14,6 @@ import Token, { IToken } from './token'; import User, { IUser } from './user'; import UserAction, { IUserAction } from './userAction'; import Workspace, { IWorkspace } from './workspace'; -import Log, { ILog } from './log'; export { BackupPrivateKey, @@ -48,7 +47,5 @@ export { UserAction, IUserAction, Workspace, - IWorkspace, - Log, - ILog + IWorkspace }; diff --git a/backend/src/models/log.ts b/backend/src/models/log.ts deleted file mode 100644 index e232d68a7..000000000 --- a/backend/src/models/log.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Schema, model, Types } from 'mongoose'; - -export interface ILog { - _id: Types.ObjectId; - user?: Types.ObjectId; - workspace: Types.ObjectId; - event: string; - groupId: string; - payload: { - numberofSecrets?: number; - environment?: string; - }, - channel: string; - ipAddress?: string; -} - -// log group consists of logs (each log is associated with 1 event) -// scenario: - -// do we in the future record old and new values for secrets? (when you log update secret, -// do you want to know what the old secret value was changed to?) - -// Option 1: - -// action 1: pushed secrets (top-level event) -// - log 1 (groupId: ABC): modified 10 secrets (sub-level event) -// ---- array of secret ids that were modified -// - log 2 (groupId: ABC): deleted 5 secrets -// ---- array of secret ids that were deleted -// - log 3 (groupId: ABC): created 10 secrets -// ---- array of secret ids that were created - -// action 2: pull secrets -// - log 4 (groupId: DEF): read 20 secrets -// ---- array of secret ids that were read - -// Option 2 (many logs): - -// action 1: pushed secrets (top-level event) -// - log 1 (groupId: ABC): modified secret abc -// - log 2 (groupId: ABC): modified secret def -// - log 3 (groupId: ABC): modified secret ghi -// - log 4 (groupId: ABC): created secret jkl -// - log 5 (groupId: ABC): created secret mno -// - log 6 (groupId: ABC): deleted secret pqr - -// action 2: pull secrets (pulling 100 secrets = 100 logs; 10 times per day, 5 people => 5000 logs) -// - log 7 (groupId: DEF): read secret abc -// - log 8 (groupId: DEF): read secret def -// - log 9 (groupId: DEF): read secret ghi -// - log 10 (groupId: DEF): read secret jkl -// - log 11 (groupId: DEF): read secret mno - -// logGroup -// ---- log (query for log groups by person and by secret etc.) - -/** - * Action: save secrets - * - - * - */ - -const logSchema = new Schema( - { - user: { - type: Schema.Types.ObjectId, - ref: 'User' - }, - workspace: { - type: Schema.Types.ObjectId, - ref: 'Workspace' - }, - event: { // CRUD secrets - type: String, - required: true - }, - groupId: { - type: String, - required: true, - }, - payload: { - secrets: [{ - type: Schema.Types.ObjectId, - ref: 'Secret' - }] - }, - channel: { - type: String, - enum: ['web', 'cli', 'auto'], - required: true - }, - ipAddress: { // store in bytes? - type: String - } - }, { - timestamps: true - } -); - -const Log = model('Log', logSchema); - -export default Log; \ No newline at end of file diff --git a/backend/src/models/logGroup.ts b/backend/src/models/logGroup.ts deleted file mode 100644 index 29527f690..000000000 --- a/backend/src/models/logGroup.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Schema, model, Types } from 'mongoose'; - -export interface ILogGroup { - workspace: Types.ObjectId, - logs: [Types.ObjectId] -} - -const logGroupSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: 'Workspace' - }, - logs: [{ - type: Schema.Types.ObjectId, - ref: 'Log' - }] - }, { - timestamps: true - } -); - -const LogGroup = model('LogGroup', logGroupSchema); - -export default LogGroup; \ No newline at end of file diff --git a/backend/src/routes/log.ts b/backend/src/routes/log.ts deleted file mode 100644 index 43d91d7ac..000000000 --- a/backend/src/routes/log.ts +++ /dev/null @@ -1,17 +0,0 @@ -import express from 'express'; -const router = express.Router(); -import { - requireAuth, - validateRequest -} from '../middleware'; -import { logController } from '../controllers'; - -// TODO: workspaceId validation -router.get( - '/:workspaceId', - requireAuth, - validateRequest, - logController.getLogs -); - -export default router; \ No newline at end of file diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 597aa2b34..2dfe58baa 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -15,7 +15,6 @@ import password from './password'; import stripe from './stripe'; import integration from './integration'; import integrationAuth from './integrationAuth'; -import log from './log'; export { signup, @@ -34,6 +33,5 @@ export { password, stripe, integration, - integrationAuth, - log + integrationAuth }; diff --git a/backend/src/routes/v1/userAction.ts b/backend/src/routes/v1/userAction.ts index 544b948e8..bbd488e06 100644 --- a/backend/src/routes/v1/userAction.ts +++ b/backend/src/routes/v1/userAction.ts @@ -4,6 +4,7 @@ import { requireAuth, validateRequest } from '../../middleware'; import { body, query } from 'express-validator'; import { userActionController } from '../../controllers/v1'; +// note: [userAction] will be deprecated in /v2 in favor of [action] router.post( '/', requireAuth, diff --git a/backend/src/variables/action.ts b/backend/src/variables/action.ts new file mode 100644 index 000000000..c8b0130d2 --- /dev/null +++ b/backend/src/variables/action.ts @@ -0,0 +1,9 @@ +const ACTION_ADD_SECRETS = 'addSecrets'; +const ACTION_DELETE_SECRETS = 'deleteSecrets'; +const ACTION_UPDATE_SECRETS = 'updateSecrets'; + +export { + ACTION_ADD_SECRETS, + ACTION_DELETE_SECRETS, + ACTION_UPDATE_SECRETS +} \ No newline at end of file diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index e284d6d5c..1d9241e2b 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -33,6 +33,11 @@ import { } from './organization'; import { SECRET_SHARED, SECRET_PERSONAL } from './secret'; import { EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS } from './event'; +import { + ACTION_ADD_SECRETS, + ACTION_UPDATE_SECRETS, + ACTION_DELETE_SECRETS +} from './action'; import { SMTP_HOST_SENDGRID, SMTP_HOST_MAILGUN } from './smtp'; import { PLAN_STARTER, PLAN_PRO } from './stripe'; @@ -67,6 +72,9 @@ export { INTEGRATION_GITHUB_API_URL, EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS, + ACTION_ADD_SECRETS, + ACTION_UPDATE_SECRETS, + ACTION_DELETE_SECRETS, INTEGRATION_OPTIONS, SMTP_HOST_SENDGRID, SMTP_HOST_MAILGUN, From 16f240596a591203a2f3beb347f0e52dd9b34761 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 27 Dec 2022 12:30:33 -0500 Subject: [PATCH 10/46] Add audit logs to pulls, still need to refactor --- .../src/controllers/v1/secretController.ts | 8 +- .../src/controllers/v2/workspaceController.ts | 8 +- backend/src/helpers/secret.ts | 89 +++++++++++++++++-- backend/src/variables/action.ts | 4 +- backend/src/variables/index.ts | 4 +- 5 files changed, 100 insertions(+), 13 deletions(-) diff --git a/backend/src/controllers/v1/secretController.ts b/backend/src/controllers/v1/secretController.ts index 238b38ced..1b756ecc7 100644 --- a/backend/src/controllers/v1/secretController.ts +++ b/backend/src/controllers/v1/secretController.ts @@ -123,7 +123,9 @@ export const pullSecrets = async (req: Request, res: Response) => { secrets = await pull({ userId: req.user._id.toString(), workspaceId, - environment + environment, + channel: channel ? channel : 'cli', + ipAddress: req.ip }); key = await Key.findOne({ @@ -188,7 +190,9 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => { secrets = await pull({ userId: req.serviceToken.user._id.toString(), workspaceId, - environment + environment, + channel: 'cli', + ipAddress: req.ip }); key = { diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index b9aa6406f..cfe1f239e 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -459,7 +459,9 @@ export const pullSecrets = async (req: Request, res: Response) => { secrets = await pull({ userId: req.user._id.toString(), workspaceId, - environment + environment, + channel, + ipAddress: req.ip }); key = await Key.findOne({ @@ -526,7 +528,9 @@ export const pullSecrets = async (req: Request, res: Response) => { secrets = await pull({ userId: req.serviceToken.user._id.toString(), workspaceId, - environment + environment, + channel: 'cli', + ipAddress: req.ip }); key = { diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index 79d99960f..93cbb648a 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -22,7 +22,8 @@ import { SECRET_PERSONAL, ACTION_ADD_SECRETS, ACTION_UPDATE_SECRETS, - ACTION_DELETE_SECRETS + ACTION_DELETE_SECRETS, + ACTION_READ_SECRETS } from '../variables'; interface V1PushSecret { @@ -78,7 +79,7 @@ const v1PushSecrets = async ({ userId, workspaceId, environment, - secrets + secrets, }: { userId: string; workspaceId: string; @@ -88,7 +89,7 @@ const v1PushSecrets = async ({ // TODO: clean up function and fix up types try { // construct useful data structures - const oldSecrets = await pullSecrets({ + const oldSecrets = await getSecrets({ userId, workspaceId, environment @@ -317,7 +318,7 @@ const v1PushSecrets = async ({ const actions: IAction[] = []; // construct useful data structures - const oldSecrets = await pullSecrets({ + const oldSecrets = await getSecrets({ userId, workspaceId, environment @@ -642,9 +643,8 @@ const v1PushSecrets = async ({ * @param {String} obj.userId -id of user to pull secrets for * @param {String} obj.workspaceId - id of workspace to pull from * @param {String} obj.environment - environment for secrets - * */ -const pullSecrets = async ({ + const getSecrets = async ({ userId, workspaceId, environment @@ -681,9 +681,84 @@ const pullSecrets = async ({ return secrets; }; +/** + * Pull secrets for user with id [userId] for workspace + * with id [workspaceId] with environment [environment] + * @param {Object} obj + * @param {String} obj.userId -id of user to pull secrets for + * @param {String} obj.workspaceId - id of workspace to pull from + * @param {String} obj.environment - environment for secrets + * @param {String} obj.channel - channel (web/cli/auto) + * @param {String} obj.ipAddress - ip address of request to push secrets + */ +const pullSecrets = async ({ + userId, + workspaceId, + environment, + channel, + ipAddress +}: { + userId: string; + workspaceId: string; + environment: string; + channel: string; + ipAddress: string; +}): Promise => { + let secrets: any; // TODO: FIX any + + try { + secrets = await getSecrets({ + userId, + workspaceId, + 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({ + name: ACTION_READ_SECRETS, + user: new Types.ObjectId(userId), + workspace: new Types.ObjectId(workspaceId), + payload: { + secretVersions: readLatestSecretVersions + } + }).save(); + + await EELogService.createLog({ + userId, + workspaceId, + actions: [readAction], + channel, + ipAddress + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to pull shared and personal secrets'); + } + + return secrets; +}; + /** * Reformat output of pullSecrets() to be compatible with how existing - * clients handle secrets + * web client handle secrets * @param {Object} obj * @param {Object} obj.secrets */ diff --git a/backend/src/variables/action.ts b/backend/src/variables/action.ts index c8b0130d2..512eb8e8d 100644 --- a/backend/src/variables/action.ts +++ b/backend/src/variables/action.ts @@ -1,9 +1,11 @@ const ACTION_ADD_SECRETS = 'addSecrets'; const ACTION_DELETE_SECRETS = 'deleteSecrets'; const ACTION_UPDATE_SECRETS = 'updateSecrets'; +const ACTION_READ_SECRETS = 'readSecrets'; export { ACTION_ADD_SECRETS, ACTION_DELETE_SECRETS, - ACTION_UPDATE_SECRETS + ACTION_UPDATE_SECRETS, + ACTION_READ_SECRETS } \ No newline at end of file diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 1d9241e2b..e8a373e74 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -36,7 +36,8 @@ import { EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS } from './event'; import { ACTION_ADD_SECRETS, ACTION_UPDATE_SECRETS, - ACTION_DELETE_SECRETS + ACTION_DELETE_SECRETS, + ACTION_READ_SECRETS } from './action'; import { SMTP_HOST_SENDGRID, SMTP_HOST_MAILGUN } from './smtp'; import { PLAN_STARTER, PLAN_PRO } from './stripe'; @@ -75,6 +76,7 @@ export { ACTION_ADD_SECRETS, ACTION_UPDATE_SECRETS, ACTION_DELETE_SECRETS, + ACTION_READ_SECRETS, INTEGRATION_OPTIONS, SMTP_HOST_SENDGRID, SMTP_HOST_MAILGUN, From 4dac65eb8a34625603eed3a5b66e7a7564a8fd7c Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 1 Jan 2023 10:54:23 +0700 Subject: [PATCH 11/46] Begin action route for getting an action by id --- backend/src/app.ts | 4 +--- .../src/ee/controllers/v1/actionController.ts | 20 +++++++++++++++++++ backend/src/ee/controllers/v1/index.ts | 4 +++- backend/src/ee/routes/v1/action.ts | 17 ++++++++++++++++ backend/src/ee/routes/v1/index.ts | 4 ++-- backend/src/ee/routes/v1/log.ts | 4 ---- 6 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 backend/src/ee/controllers/v1/actionController.ts create mode 100644 backend/src/ee/routes/v1/action.ts delete mode 100644 backend/src/ee/routes/v1/log.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 4e8e23fc0..8320a7d76 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -13,8 +13,7 @@ import { apiLimiter } from './helpers/rateLimiter'; import { workspace as eeWorkspaceRouter, - secret as eeSecretRouter, - log as eeLogRouter, + secret as eeSecretRouter } from './ee/routes/v1'; import { signup as v1SignupRouter, @@ -70,7 +69,6 @@ if (NODE_ENV === 'production') { // (EE) routes app.use('/api/v1/secret', eeSecretRouter); app.use('/api/v1/workspace', eeWorkspaceRouter); -app.use('/api/v1/log', eeLogRouter); // v1 routes app.use('/api/v1/signup', v1SignupRouter); diff --git a/backend/src/ee/controllers/v1/actionController.ts b/backend/src/ee/controllers/v1/actionController.ts new file mode 100644 index 000000000..20b470037 --- /dev/null +++ b/backend/src/ee/controllers/v1/actionController.ts @@ -0,0 +1,20 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { Action } from '../../models'; + +export const getAction = (req: Request, res: Response) => { + let action; + // try { + // const { actionId } = req.params; + + // action = await Action.findById(actionId); + + + // } catch (err) { + + // } + + return res.status(200).send({ + action + }); +} \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/index.ts b/backend/src/ee/controllers/v1/index.ts index 23880070d..2a082de70 100644 --- a/backend/src/ee/controllers/v1/index.ts +++ b/backend/src/ee/controllers/v1/index.ts @@ -1,9 +1,11 @@ import * as stripeController from './stripeController'; import * as secretController from './secretController'; import * as workspaceController from './workspaceController'; +import * as actionController from './actionController'; export { stripeController, secretController, - workspaceController + workspaceController, + actionController } \ No newline at end of file diff --git a/backend/src/ee/routes/v1/action.ts b/backend/src/ee/routes/v1/action.ts new file mode 100644 index 000000000..5dca83cf9 --- /dev/null +++ b/backend/src/ee/routes/v1/action.ts @@ -0,0 +1,17 @@ +import express from 'express'; +const router = express.Router(); +import { + validateRequest +} from '../../../middleware'; +import { param } from 'express-validator'; +import { actionController } from '../../controllers/v1'; + +// TODO: put into action controller +router.get( + '/:actionId', + param('actionId').exists().trim(), + validateRequest, + actionController.getAction +); + +export default router; \ No newline at end of file diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 810a050a6..02fc80939 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,9 +1,9 @@ import secret from './secret'; import workspace from './workspace'; -import log from './log'; +import action from './action'; export { secret, workspace, - log + action } \ No newline at end of file diff --git a/backend/src/ee/routes/v1/log.ts b/backend/src/ee/routes/v1/log.ts deleted file mode 100644 index d90cb867c..000000000 --- a/backend/src/ee/routes/v1/log.ts +++ /dev/null @@ -1,4 +0,0 @@ -import express from 'express'; -const router = express.Router(); - -export default router; \ No newline at end of file From 9c83808e2e2701c2d1a9c5f3164c26a7f4d75917 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Sat, 31 Dec 2022 20:17:40 -0800 Subject: [PATCH 12/46] Added populate statement --- backend/src/ee/controllers/v1/workspaceController.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 1c9a71bc8..cff7f4a98 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -51,7 +51,8 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { logs = await Log.find(filters) .skip(offset) .limit(limit) - .populate('actions'); + .populate('actions') + .populate('user'); } catch (err) { Sentry.setUser({ email: req.user.email }); From 01673427228092a467a97eb68f2c18acaf546e5e Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Sun, 1 Jan 2023 18:27:31 -0800 Subject: [PATCH 13/46] Improved frontend for activity logs --- .../ee/controllers/v1/workspaceController.ts | 1 + frontend/components/basic/EventFilter.tsx | 52 ++- .../components/basic/table/ActivityTable.tsx | 141 ------- frontend/ee/api/secrets/GetProjectLogs.ts | 43 +++ frontend/ee/api/secrets/GetSecretVersions.ts | 4 +- frontend/ee/components/ActivitySideBar.tsx | 80 ++++ frontend/ee/components/ActivityTable.tsx | 132 +++++++ frontend/ee/utilities/findTextDifferences.ts | 346 ++++++++++++++++++ frontend/ee/utilities/timeSince.ts | 35 ++ frontend/pages/activity/[id].tsx | 207 +++++------ frontend/public/locales/en/activity.json | 7 + frontend/tailwind.config.js | 9 +- 12 files changed, 757 insertions(+), 300 deletions(-) delete mode 100644 frontend/components/basic/table/ActivityTable.tsx create mode 100644 frontend/ee/api/secrets/GetProjectLogs.ts create mode 100644 frontend/ee/components/ActivitySideBar.tsx create mode 100644 frontend/ee/components/ActivityTable.tsx create mode 100644 frontend/ee/utilities/findTextDifferences.ts create mode 100644 frontend/ee/utilities/timeSince.ts create mode 100644 frontend/public/locales/en/activity.json diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index cff7f4a98..2f8020ef0 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -49,6 +49,7 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { filters.workspace = workspaceId; logs = await Log.find(filters) + .sort({ createdAt: -1 }) .skip(offset) .limit(limit) .populate('actions') diff --git a/frontend/components/basic/EventFilter.tsx b/frontend/components/basic/EventFilter.tsx index 5a9506f0e..d4fe36193 100644 --- a/frontend/components/basic/EventFilter.tsx +++ b/frontend/components/basic/EventFilter.tsx @@ -1,60 +1,50 @@ import React from 'react'; import { Fragment } from 'react'; +import { useTranslation } from "next-i18next"; import { faAngleDown, - faCheck, - faDownload, + faEye, faPlus, - faUpload, + faShuffle, faX } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Listbox, Transition } from '@headlessui/react'; -import guidGenerator from '../utilities/randomId'; -import Button from './buttons/Button'; - interface ListBoxProps { selected: string; select: (event: string) => void; - data: string[]; - text?: string; - buttonAction?: () => void; - isFull?: boolean; } const eventOptions = [ { - name: 'Secrets Pushed', - icon: faUpload + name: 'addSecrets', + icon: faPlus }, { - name: 'Secrets Pulled', - icon: faDownload + name: 'readSecrets', + icon: faEye + }, + { + name: 'updateSecrets', + icon: faShuffle } ]; /** - * This is the component that we use for drop down lists. + * This is the component that we use for the event picker in the activity logs tab. * @param {object} obj - * @param {string} obj.selected - the item that is currently selected - * @param {function} obj.select - what happends if you select the item inside a list - * @param {string[]} obj.data - all the options available - * @param {string} obj.text - the text that shows us in front of the select option - * @param {function} obj.buttonAction - if there is a button at the bottom of the list, this is the action that happens when you click the button - * @param {string} obj.width - button width - * @returns + * @param {string} obj.selected - the event that is currently selected + * @param {function} obj.select - an action that happens when an item is selected */ export default function EventFilter({ selected, - select, - data, - text, - buttonAction, - isFull + select }: ListBoxProps): JSX.Element { + const { t } = useTranslation(); + return ( - +
{selected != '' ? ( @@ -84,9 +74,9 @@ export default function EventFilter({ {({ selected }) => ( <> @@ -96,7 +86,7 @@ export default function EventFilter({ }`} > {' '} - {event.name} + {t("activity:event." + event.name)} )} diff --git a/frontend/components/basic/table/ActivityTable.tsx b/frontend/components/basic/table/ActivityTable.tsx deleted file mode 100644 index 0f5f3eb6b..000000000 --- a/frontend/components/basic/table/ActivityTable.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useRouter } from 'next/router'; -import { - faAngleDown, - faAngleRight, - faX -} from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; - -import guidGenerator from '../../utilities/randomId'; - -interface ActivityTableProps { - eventName: string; - user: string; - source: string; - time: Date; -} - -function timeSince(date: Date) { - const seconds = Math.floor( - ((new Date() as any) - (date as any)) / 1000 - ) as number; - - let interval = seconds / 31536000; - - if (interval > 1) { - return Math.floor(interval) + ' years ago'; - } - interval = seconds / 2592000; - if (interval > 1) { - return Math.floor(interval) + ' months ago'; - } - interval = seconds / 86400; - if (interval > 1) { - return Math.floor(interval) + ' days ago'; - } - interval = seconds / 3600; - if (interval > 1) { - return Math.floor(interval) + ' hours ago'; - } - interval = seconds / 60; - if (interval > 1) { - return Math.floor(interval) + ' minutes ago'; - } - return Math.floor(seconds) + ' seconds ago'; -} - -const ActivityLogsRow = ({ row }: { row: ActivityTableProps }): JSX.Element => { - const [payloadOpened, setPayloadOpened] = useState(false); - return ( - <> - -
setPayloadOpened(!payloadOpened)} - className="border-mineshaft-700 border-t text-gray-300 flex items-center cursor-pointer" - > - -
- - {row.eventName} - - - {row.user} - - - {row.source} - - - {timeSince(row.time)} - - {/* -
-
- */} - - {payloadOpened && ( - - -
-
Timestamp
-
2022-12-16T04:02:44.517Z
-
-
-
Number of Secrets
-
32
-
-
-
IP Address
-
159.223.164.24
-
- - - )} - - ); -}; - -/** - * This is the table for activity logs (one of the tabs) - * @param {*} props - * @returns - */ -const ActivityTable = ({ data }: { data: ActivityTableProps[] }) => { - return ( -
-
-
- - - - - - - - - - - - - {data.map((row, index) => { - return ; - })} - -
EventUserSourceTime
-
-
- ); -}; - -export default ActivityTable; diff --git a/frontend/ee/api/secrets/GetProjectLogs.ts b/frontend/ee/api/secrets/GetProjectLogs.ts new file mode 100644 index 000000000..d5324355c --- /dev/null +++ b/frontend/ee/api/secrets/GetProjectLogs.ts @@ -0,0 +1,43 @@ +import SecurityClient from '~/utilities/SecurityClient'; + + +interface workspaceProps { + workspaceId: string; + offset: number; + limit: number; + filters: object; +} + +/** + * This function fetches the activity logs for a certain project + * @param {object} obj + * @param {string} obj.workspaceId - workspace id for which we are trying to get project log + * @param {object} obj.offset - teh starting point of logs that we want to pull + * @param {object} obj.limit - how many logs will we output + * @param {object} obj.filters + * @returns + */ +const getProjectLogs = async ({ workspaceId, offset, limit, filters }: workspaceProps) => { + return SecurityClient.fetchCall( + '/api/v1/workspace/' + workspaceId + '/logs?' + + new URLSearchParams({ + offset: String(offset), + limit: String(limit), + filters: JSON.stringify(filters) + }), + { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + ).then(async (res) => { + if (res && res.status == 200) { + return (await res.json()).logs; + } else { + console.log('Failed to get project logs'); + } + }); +}; + +export default getProjectLogs; diff --git a/frontend/ee/api/secrets/GetSecretVersions.ts b/frontend/ee/api/secrets/GetSecretVersions.ts index 8185234e6..7b3840399 100644 --- a/frontend/ee/api/secrets/GetSecretVersions.ts +++ b/frontend/ee/api/secrets/GetSecretVersions.ts @@ -17,7 +17,7 @@ interface secretVersionProps { */ const getSecretVersions = async ({ secretId, offset, limit }: secretVersionProps) => { return SecurityClient.fetchCall( - '/api/v1/secret/' + secretId + '/secret-versions?'+ + '/api/v1/secret/' + secretId + '/secret-versions?' + new URLSearchParams({ offset: String(offset), limit: String(limit) @@ -32,7 +32,7 @@ const getSecretVersions = async ({ secretId, offset, limit }: secretVersionProps if (res && res.status == 200) { return await res.json(); } else { - console.log('Failed to get project secrets'); + console.log('Failed to get secret version history'); } }); }; diff --git a/frontend/ee/components/ActivitySideBar.tsx b/frontend/ee/components/ActivitySideBar.tsx new file mode 100644 index 000000000..d96a3f6ab --- /dev/null +++ b/frontend/ee/components/ActivitySideBar.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from "next-i18next"; +import { faX } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import patienceDiff from 'ee/utilities/findTextDifferences'; + +import DashboardInputField from '../../components/dashboard/DashboardInputField'; + + +const secretChanges = [{ + "oldSecret": "secret1", + "newSecret": "ecret2" +}, { + "oldSecret": "secret1", + "newSecret": "sercet2" +}, { + "oldSecret": "localhosta:8080", + "newSecret": "aaaalocalhoats:3000" +}] + + +interface SideBarProps { + toggleSidebar: (value: string[]) => void; + sidebarData: string[]; + currentEvent: string; +} + +/** + * @param {object} obj + * @param {function} obj.toggleSidebar - function that opens or closes the sidebar + * @param {string[]} obj.secretIds - data of payload + * @param {string} obj.currentEvent - the event name for which a sidebar is being displayed + * @returns the sidebar with the payload of user activity logs + */ +const ActivitySideBar = ({ + toggleSidebar, + sidebarData, + currentEvent +}: SideBarProps) => { + const { t } = useTranslation(); + + return
+
+
+

{t("activity:event." + currentEvent)}

+
toggleSidebar([])}> + +
+
+
+ {currentEvent == 'readSecrets' && sidebarData.map((item, id) => + <> +
Key {id}
+ {}} + type="varName" + position={1} + value={"a" + item} + isDuplicate={false} + blurred={false} + /> + + )} + {currentEvent == 'updateSecrets' && sidebarData.map((item, id) => + secretChanges.map(secretChange => + <> +
Secret Name {id}
+
+
- {patienceDiff(secretChange.oldSecret.split(''), secretChange.newSecret.split(''), false).lines.map((character, id) => character.aIndex != -1 && {character.line})}
+
+ {patienceDiff(secretChange.oldSecret.split(''), secretChange.newSecret.split('')).lines.map((character, id) => character.bIndex != -1 && {character.line})}
+
+ + ))} +
+
+ +
+}; + +export default ActivitySideBar; diff --git a/frontend/ee/components/ActivityTable.tsx b/frontend/ee/components/ActivityTable.tsx new file mode 100644 index 000000000..a25920202 --- /dev/null +++ b/frontend/ee/components/ActivityTable.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useTranslation } from "next-i18next"; +import { + faAngleDown, + faAngleRight, + faUpRightFromSquare, + faX +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import timeSince from 'ee/utilities/timeSince'; + +import guidGenerator from '../../components/utilities/randomId'; + + +interface PayloadProps { + name: string; + secretVersions: string[]; +} + +interface logData { + _id: string; + channel: string; + createdAt: string; + ipAddress: string; + user: string; + payload: PayloadProps[]; +} + + +/** + * + * @param obj + * @param {function} obj.setCurrentEvent - specify the name of the event for which the sidebar is being opened + * @returns + */ +const ActivityLogsRow = ({ row, toggleSidebar, setCurrentEvent }: { row: logData, toggleSidebar: (value: string[]) => void; setCurrentEvent: (value: string) => void; }) => { + const [payloadOpened, setPayloadOpened] = useState(false); + const { t } = useTranslation(); + + return ( + <> + + setPayloadOpened(!payloadOpened)} + className="border-mineshaft-700 border-t text-gray-300 flex items-center cursor-pointer" + > + + + + {row.payload?.map(action => String(action.secretVersions.length) + " " + t("activity:event." + action.name)).join(" and ")} + + + {row.user} + + + {row.channel} + + + {timeSince(new Date(row.createdAt))} + + + {payloadOpened && + + + Timestamp + {row.createdAt} + } + {payloadOpened && + row.payload?.map((action, index) => + + + {t("activity:event." + action.name)} + { + toggleSidebar(action.secretVersions); + setCurrentEvent(action.name); + }}> + {action.secretVersions.length + (action.secretVersions.length != 1 ? " secrets" : " secret")} + + + )} + {payloadOpened && + + + IP Address + {row.ipAddress} + } + + ); +}; + +/** + * This is the table for activity logs (one of the tabs) + * @param {object} obj + * @param {logData} obj.data - data for user activity logs + * @param {function} obj.toggleSidebar - function that opens or closes the sidebar + * @param {function} obj.setCurrentEvent - specify the name of the event for which the sidebar is being opened + * @returns + */ +const ActivityTable = ({ data, toggleSidebar, setCurrentEvent }: { data: logData[], toggleSidebar: (value: string[]) => void; setCurrentEvent: (value: string) => void; }) => { + return ( +
+
+
+ + + + + + + + + + + + + {data?.map((row, index) => { + return ; + })} + +
EventUserSourceTime
+
+
+ ); +}; + +export default ActivityTable; diff --git a/frontend/ee/utilities/findTextDifferences.ts b/frontend/ee/utilities/findTextDifferences.ts new file mode 100644 index 000000000..ffd847a77 --- /dev/null +++ b/frontend/ee/utilities/findTextDifferences.ts @@ -0,0 +1,346 @@ +/** + * + * @param textOld - old secret + * @param textNew - new (updated) secret + * @param diffPlusFlag - a flag for whether we want to detect moving segments + * - doesn't work in some examples (e.g., when we have a full reverse ordering of the text) + * @returns + */ +function patienceDiff(textOld: string[], textNew: string[], diffPlusFlag?: boolean) { + + /** + * findUnique finds all unique values in arr[lo..hi], inclusive. This + * function is used in preparation for determining the longest common + * subsequence. Specifically, it first reduces the array range in question + * to unique values. + * @param chars - an array of characters + * @param lo + * @param hi + * @returns - an ordered Map, with the arr[i] value as the Map key and the + * array index i as the Map value. + */ + function findUnique(chars: string[], lo: number, hi: number) { + const characterMap = new Map(); + + for (let i=lo; i<=hi; i++) { + const character = chars[i]; + + if (characterMap.has(character)) { + characterMap.get(character).count++; + characterMap.get(character).index = i; + } else { + characterMap.set(character, { count: 1, index: i }); + } + } + + characterMap.forEach((val, key, map) => { + if (val.count !== 1) { + map.delete(key); + } else { + map.set(key, val.index); + } + }); + + return characterMap; + } + + /** + * @param aArray + * @param aLo + * @param aHi + * @param bArray + * @param bLo + * @param bHi + * @returns an ordered Map, with the Map key as the common line between aArray + * and bArray, with the Map value as an object containing the array indexes of + * the matching unique lines. + * + */ + function uniqueCommon(aArray: string[], aLo: number, aHi: number, bArray: string[], bLo: number, bHi: number) { + const ma = findUnique(aArray, aLo, aHi); + const mb = findUnique(bArray, bLo, bHi); + + ma.forEach((val, key, map) => { + if (mb.has(key)) { + map.set(key, { + indexA: val, + indexB: mb.get(key) + }); + } else { + map.delete(key); + } + }); + + return ma; + } + + /** + * longestCommonSubsequence takes an ordered Map from the function uniqueCommon + * and determines the Longest Common Subsequence (LCS). + * @param abMap + * @returns an ordered array of objects containing the array indexes of the + * matching lines for a LCS. + */ + function longestCommonSubsequence(abMap: Map) { + const ja: any = []; + + // First, walk the list creating the jagged array. + abMap.forEach((val, key, map) => { + let i = 0; + + while (ja[i] && ja[i][ja[i].length - 1].indexB < val.indexB) { + i++; + } + + if (!ja[i]) { + ja[i] = []; + } + + if (0 < i) { + val.prev = ja[i-1][ja[i - 1].length - 1]; + } + ja[i].push(val); + }); + + // Now, pull out the longest common subsequence. + let lcs: any[] = []; + + if (0 < ja.length) { + const n = ja.length - 1; + lcs = [ja[n][ja[n].length - 1]]; + + while (lcs[lcs.length - 1].prev) { + lcs.push(lcs[lcs.length - 1].prev); + } + } + + return lcs.reverse(); + } + + // "result" is the array used to accumulate the textOld that are deleted, the + // lines that are shared between textOld and textNew, and the textNew that were + // inserted. + + const result: any[] = []; + let deleted = 0; + let inserted = 0; + + // aMove and bMove will contain the lines that don't match, and will be returned + // for possible searching of lines that moved. + + const aMove: any[] = []; + const aMoveIndex: any[] = []; + const bMove: any[] = []; + const bMoveIndex: any[] = []; + + /** + * addToResult simply pushes the latest value onto the "result" array. This + * array captures the diff of the line, aIndex, and bIndex from the textOld + * and textNew array. + * @param aIndex + * @param bIndex + */ + function addToResult(aIndex: number, bIndex: number) { + if (bIndex < 0) { + aMove.push(textOld[aIndex]); + aMoveIndex.push(result.length); + deleted++; + } else if (aIndex < 0) { + bMove.push(textNew[bIndex]); + bMoveIndex.push(result.length); + inserted++; + } + + result.push({ + line: 0 <= aIndex ? textOld[aIndex] : textNew[bIndex], + aIndex: aIndex, + bIndex: bIndex, + }); + } + + /** + * addSubMatch handles the lines between a pair of entries in the LCS. Thus, + * this function might recursively call recurseLCS to further match the lines + * between textOld and textNew. + * @param aLo + * @param aHi + * @param bLo + * @param bHi + */ + function addSubMatch(aLo: number, aHi: number, bLo: number, bHi: number) { + // Match any lines at the beginning of textOld and textNew. + while (aLo <= aHi && bLo <= bHi && textOld[aLo] === textNew[bLo]) { + addToResult(aLo++, bLo++); + } + + // Match any lines at the end of textOld and textNew, but don't place them + // in the "result" array just yet, as the lines between these matches at + // the beginning and the end need to be analyzed first. + + const aHiTemp = aHi; + while (aLo <= aHi && bLo <= bHi && textOld[aHi] === textNew[bHi]) { + aHi--; + bHi--; + } + + // Now, check to determine with the remaining lines in the subsequence + // whether there are any unique common lines between textOld and textNew. + // + // If not, add the subsequence to the result (all textOld having been + // deleted, and all textNew having been inserted). + // + // If there are unique common lines between textOld and textNew, then let's + // recursively perform the patience diff on the subsequence. + + const uniqueCommonMap = uniqueCommon(textOld, aLo, aHi, textNew, bLo, bHi); + + if (uniqueCommonMap.size === 0) { + while (aLo <= aHi) { + addToResult(aLo++, -1); + } + + while (bLo <= bHi) { + addToResult(-1, bLo++); + } + } else { + recurseLCS(aLo, aHi, bLo, bHi, uniqueCommonMap); + } + + // Finally, let's add the matches at the end to the result. + while (aHi < aHiTemp) { + addToResult(++aHi, ++bHi); + } + } + + /** + * recurseLCS finds the longest common subsequence (LCS) between the arrays + * textOld[aLo..aHi] and textNew[bLo..bHi] inclusive. Then for each subsequence + * recursively performs another LCS search (via addSubMatch), until there are + * none found, at which point the subsequence is dumped to the result. + * @param aLo + * @param aHi + * @param bLo + * @param bHi + * @param uniqueCommonMap + */ + function recurseLCS(aLo: number, aHi: number, bLo: number, bHi: number, uniqueCommonMap?: any) { + const x = longestCommonSubsequence(uniqueCommonMap || uniqueCommon(textOld, aLo, aHi, textNew, bLo, bHi)); + + if (x.length === 0) { + addSubMatch(aLo, aHi, bLo, bHi); + } else { + if (aLo < x[0].indexA || bLo < x[0].indexB) { + addSubMatch(aLo, x[0].indexA - 1, bLo, x[0].indexB - 1); + } + + let i; + for (i = 0; i < x.length - 1; i++) { + addSubMatch(x[i].indexA, x[i+1].indexA - 1, x[i].indexB, x[i+1].indexB - 1); + } + + if (x[i].indexA <= aHi || x[i].indexB <= bHi) { + addSubMatch(x[i].indexA, aHi, x[i].indexB, bHi); + } + } + } + + recurseLCS(0, textOld.length - 1, 0, textNew.length - 1); + + if (diffPlusFlag) { + return { + lines: result, + lineCountDeleted: deleted, + lineCountInserted: inserted, + lineCountMoved: 0, + aMove: aMove, + aMoveIndex: aMoveIndex, + bMove: bMove, + bMoveIndex: bMoveIndex, + }; + } + + return { + lines: result, + lineCountDeleted: deleted, + lineCountInserted: inserted, + lineCountMoved: 0, + }; +} + +/** + * use: patienceDiffPlus( textOld[], textNew[] ) + * + * where: + * textOld[] contains the original text lines. + * textNew[] contains the new text lines. + * + * returns an object with the following properties: + * lines[] with properties of: + * line containing the line of text from textOld or textNew. + * aIndex referencing the index in aLine[]. + * bIndex referencing the index in textNew[]. + * (Note: The line is text from either textOld or textNew, with aIndex and bIndex + * referencing the original index. If aIndex === -1 then the line is new from textNew, + * and if bIndex === -1 then the line is old from textOld.) + * moved is true if the line was moved from elsewhere in textOld[] or textNew[]. + * lineCountDeleted is the number of lines from textOld[] not appearing in textNew[]. + * lineCountInserted is the number of lines from textNew[] not appearing in textOld[]. + * lineCountMoved is the number of lines that moved. + */ + +function patienceDiffPlus(textOld: string[], textNew: string[]) { + + const difference = patienceDiff(textOld, textNew, true); + + let aMoveNext = difference.aMove; + let aMoveIndexNext = difference.aMoveIndex; + let bMoveNext = difference.bMove; + let bMoveIndexNext = difference.bMoveIndex; + + delete difference.aMove; + delete difference.aMoveIndex; + delete difference.bMove; + delete difference.bMoveIndex; + + let lastLineCountMoved; + + do { + const aMove = aMoveNext; + const aMoveIndex = aMoveIndexNext; + const bMove = bMoveNext; + const bMoveIndex = bMoveIndexNext; + + aMoveNext = []; + aMoveIndexNext = []; + bMoveNext = []; + bMoveIndexNext = []; + + const subDiff = patienceDiff(aMove!, bMove!); + + lastLineCountMoved = difference.lineCountMoved; + + subDiff.lines.forEach((v, i) => { + + if (0 <= v.aIndex && 0 <= v.bIndex) { + + difference.lines[aMoveIndex![v.aIndex]].moved = true; + difference.lines[bMoveIndex![v.bIndex]].aIndex = aMoveIndex![v.aIndex]; + difference.lines[bMoveIndex![v.bIndex]].moved = true; + difference.lineCountInserted--; + difference.lineCountDeleted--; + difference.lineCountMoved++; + } else if (v.bIndex < 0) { + aMoveNext!.push(aMove![v.aIndex]); + aMoveIndexNext!.push(aMoveIndex![v.aIndex]); + } else { + bMoveNext!.push(bMove![v.bIndex]); + bMoveIndexNext!.push(bMoveIndex![v.bIndex]); + } + }); + } while (0 < difference.lineCountMoved - lastLineCountMoved); + + return difference; + +} + +export default patienceDiff; diff --git a/frontend/ee/utilities/timeSince.ts b/frontend/ee/utilities/timeSince.ts new file mode 100644 index 000000000..b79249967 --- /dev/null +++ b/frontend/ee/utilities/timeSince.ts @@ -0,0 +1,35 @@ +/** + * Time since a certain date + * @param {Date} date - the timestamp got which we want to understand how long ago it happened + * @returns {String} text - how much time has passed since a certain timestamp + */ +function timeSince(date: Date) { + const seconds = Math.floor( + ((new Date() as any) - (date as any)) / 1000 + ) as number; + + let interval = seconds / 31536000; + + if (interval > 1) { + return Math.floor(interval) + ' years ago'; + } + interval = seconds / 2592000; + if (interval > 1) { + return Math.floor(interval) + ' months ago'; + } + interval = seconds / 86400; + if (interval > 1) { + return Math.floor(interval) + ' days ago'; + } + interval = seconds / 3600; + if (interval > 1) { + return Math.floor(interval) + ' hours ago'; + } + interval = seconds / 60; + if (interval > 1) { + return Math.floor(interval) + ' minutes ago'; + } + return Math.floor(seconds) + ' seconds ago'; +} + +export default timeSince; diff --git a/frontend/pages/activity/[id].tsx b/frontend/pages/activity/[id].tsx index 9b51d7f74..f384ae69f 100644 --- a/frontend/pages/activity/[id].tsx +++ b/frontend/pages/activity/[id].tsx @@ -1,137 +1,90 @@ import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; +import { useTranslation } from "next-i18next"; +import ActivitySideBar from 'ee/components/ActivitySideBar'; +import Button from '~/components/basic/buttons/Button'; import EventFilter from '~/components/basic/EventFilter'; -import ActivityTable from '~/components/basic/table/ActivityTable'; import NavHeader from '~/components/navigation/NavHeader'; -import onboardingCheck from '~/components/utilities/checks/OnboardingCheck'; +import { getTranslatedServerSideProps } from '~/components/utilities/withTranslateProps'; -const data = [ - { - eventName: 'Secrets Pulled', - user: 'matsiiako@gmail.com', - source: 'CLI', - time: new Date() - }, - { - eventName: 'Secrets Pushed', - user: 'matsiiako@gmail.com', - source: 'Web', - time: new Date() - }, - { - eventName: 'Secrets Pulled', - user: 'matsiiako@gmail.com', - source: 'CLI', - time: new Date() - }, - { - eventName: 'Secrets Pushed', - user: 'matsiiako@gmail.com', - source: 'Web', - time: new Date() - }, - { - eventName: 'Secrets Pulled', - user: 'matsiiako@gmail.com', - source: 'CLI', - time: new Date() - }, - { - eventName: 'Secrets Pushed', - user: 'matsiiako@gmail.com', - source: 'Web', - time: new Date() - }, - { - eventName: 'Secrets Pulled', - user: 'matsiiako@gmail.com', - source: 'CLI', - time: new Date() - }, - { - eventName: 'Secrets Pushed', - user: 'matsiiako@gmail.com', - source: 'Web', - time: new Date() - }, - { - eventName: 'Secrets Pulled', - user: 'matsiiako@gmail.com', - source: 'CLI', - time: new Date() - }, - { - eventName: 'Secrets Pushed', - user: 'matsiiako@gmail.com', - source: 'Web', - time: new Date() - }, - { - eventName: 'Secrets Pulled', - user: 'matsiiako@gmail.com', - source: 'CLI', - time: new Date() - }, - { - eventName: 'Secrets Pushed', - user: 'matsiiako@gmail.com', - source: 'Web', - time: new Date() - }, - { - eventName: 'Secrets Pulled', - user: 'matsiiako@gmail.com', - source: 'CLI', - time: new Date() - }, - { - eventName: 'Secrets Pushed', - user: 'matsiiako@gmail.com', - source: 'Web', - time: new Date() - }, - { - eventName: 'Secrets Pulled', - user: 'matsiiako@gmail.com', - source: 'CLI', - time: new Date() - }, - { - eventName: 'Secrets Pushed', - user: 'matsiiako@gmail.com', - source: 'Web', - time: new Date() - } -]; +import getProjectLogs from '../../ee/api/secrets/GetProjectLogs'; +import ActivityTable from '../../ee/components/ActivityTable'; + + +interface logData { + _id: string; + channel: string; + createdAt: string; + ipAddress: string; + user: { + email: string; + }; + actions: { + name: string; + payload: { + secretVersions: string[]; + } + }[] +} + +interface PayloadProps { + name: string; + secretVersions: string[]; +} + +interface logDataPoint { + _id: string; + channel: string; + createdAt: string; + ipAddress: string; + user: string; + payload: PayloadProps[]; +} /** - * This tab is called Home because in the future it will include some company news, - * updates, roadmap, relavant blogs, etc. Currently it only has the setup instruction - * for the new users + * This is the tab that includes all of the user activity logs */ export default function Activity() { const router = useRouter(); - const [hasUserClickedSlack, setHasUserClickedSlack] = useState(false); - const [hasUserClickedIntro, setHasUserClickedIntro] = useState(false); - const [hasUserStarred, setHasUserStarred] = useState(false); - const [hasUserPushedSecrets, setHasUserPushedSecrets] = useState(false); - const [usersInOrg, setUsersInOrg] = useState(false); const [eventChosen, setEventChosen] = useState(''); + const [logsData, setLogsData] = useState([]); + const [currentOffset, setCurrentOffset] = useState(0); + const currentLimit = 10; + const [sidebarData, toggleSidebar] = useState([]) + const [currentEvent, setCurrentEvent] = useState(""); + const { t } = useTranslation(); useEffect(() => { - onboardingCheck({ - setHasUserClickedIntro, - setHasUserClickedSlack, - setHasUserPushedSecrets, - setHasUserStarred, - setUsersInOrg - }); - }, []); + const getLogData = async () => { + const tempLogsData = await getProjectLogs({ workspaceId: String(router.query.id), offset: currentOffset, limit: currentLimit, filters: {} }) + setLogsData(logsData.concat(tempLogsData.map((log: logData) => { + return { + _id: log._id, + channel: log.channel, + createdAt: log.createdAt, + ipAddress: log.ipAddress, + user: log.user.email, + payload: log.actions.map(action => { + return { + name: action.name, + secretVersions: action.payload.secretVersions + } + }) + } + }))) + } + getLogData(); + }, [currentLimit, currentOffset]); + + const loadMoreLogs = () => { + setCurrentOffset(currentOffset + currentLimit); + } return (
+ {sidebarData.length > 0 && }

Activity Logs

@@ -140,22 +93,32 @@ export default function Activity() { Event history limited to the last 12 months.

- {/* Licence Required
-
*/} +
- eventChosen != '' ? event.eventName == eventChosen : event - )} + data={logsData!.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .filter((log) => + eventChosen != '' ? log.payload?.map(action => t("activity:event." + action.name)).includes(eventChosen) : true + ) + } + toggleSidebar={toggleSidebar} + setCurrentEvent={setCurrentEvent} /> +
+
+
+
); } Activity.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(["activity"]); \ No newline at end of file diff --git a/frontend/public/locales/en/activity.json b/frontend/public/locales/en/activity.json new file mode 100644 index 000000000..b84d570ae --- /dev/null +++ b/frontend/public/locales/en/activity.json @@ -0,0 +1,7 @@ +{ + "event": { + "readSecrets": "Secrets Viewed", + "updateSecrets": "Secrets Updated", + "addSecrets": "Secrets Added" + } +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 6f3d038cf..81e97e6ed 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1238,6 +1238,7 @@ module.exports = { content: [ "./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}", + "./ee/**/*.{js,ts,jsx,tsx}", ], theme: { extend: { @@ -1382,12 +1383,12 @@ module.exports = { "0%": { transform: "scale(0.2)", opacity: 0, - transform: "translateY(120%)", + // transform: "translateY(120%)", }, "100%": { transform: "scale(1)", opacity: 1, - transform: "translateY(100%)", + // transform: "translateY(100%)", }, }, popright: { @@ -1410,12 +1411,12 @@ module.exports = { "0%": { transform: "scale(0.2)", opacity: 0, - transform: "translateY(80%)", + // transform: "translateY(80%)", }, "100%": { transform: "scale(1)", opacity: 1, - transform: "translateY(100%)", + // transform: "translateY(100%)", }, }, }, From a8f0c391bc4d38c7eddfe7e486a8c936ed5a71d0 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 2 Jan 2023 14:18:49 +0700 Subject: [PATCH 14/46] Finish v1 audit logs, secret versioning, version all unversioned secrets --- backend/src/app.ts | 4 +- .../src/controllers/v2/workspaceController.ts | 2 - .../src/ee/controllers/v1/actionController.ts | 27 ++- .../ee/controllers/v1/workspaceController.ts | 17 +- backend/src/ee/helpers/action.ts | 112 ++++++++++ backend/src/ee/helpers/log.ts | 1 + backend/src/ee/helpers/secret.ts | 29 ++- backend/src/ee/models/action.ts | 10 +- backend/src/ee/models/log.ts | 20 +- backend/src/ee/routes/v1/workspace.ts | 1 - backend/src/ee/services/EELogService.ts | 36 +++- backend/src/ee/services/EESecretService.ts | 25 ++- backend/src/helpers/database.ts | 78 +++++++ backend/src/helpers/secret.ts | 199 ++++-------------- backend/src/index.ts | 4 +- backend/src/models/user.ts | 3 +- backend/src/services/DatabaseService.ts | 16 ++ backend/src/services/database.ts | 10 - backend/src/services/index.ts | 2 + 19 files changed, 401 insertions(+), 195 deletions(-) create mode 100644 backend/src/ee/helpers/action.ts create mode 100644 backend/src/helpers/database.ts create mode 100644 backend/src/services/DatabaseService.ts delete mode 100644 backend/src/services/database.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 8320a7d76..9fba18c67 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -13,7 +13,8 @@ import { apiLimiter } from './helpers/rateLimiter'; import { workspace as eeWorkspaceRouter, - secret as eeSecretRouter + secret as eeSecretRouter, + action as eeActionRouter } from './ee/routes/v1'; import { signup as v1SignupRouter, @@ -69,6 +70,7 @@ if (NODE_ENV === 'production') { // (EE) routes app.use('/api/v1/secret', eeSecretRouter); app.use('/api/v1/workspace', eeWorkspaceRouter); +app.use('/api/v1/action', eeActionRouter); // v1 routes app.use('/api/v1/signup', v1SignupRouter); diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index cfe1f239e..0aafcd525 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -369,7 +369,6 @@ export const getWorkspaceServiceTokens = async ( */ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { // upload (encrypted) secrets to workspace with id [workspaceId] - try { let { secrets }: { secrets: V2PushSecret[] } = req.body; const { keys, environment, channel } = req.body; @@ -400,7 +399,6 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { keys }); - if (postHogClient) { postHogClient.capture({ event: 'secrets pushed', diff --git a/backend/src/ee/controllers/v1/actionController.ts b/backend/src/ee/controllers/v1/actionController.ts index 20b470037..b136b0fa4 100644 --- a/backend/src/ee/controllers/v1/actionController.ts +++ b/backend/src/ee/controllers/v1/actionController.ts @@ -1,18 +1,29 @@ import { Request, Response } from 'express'; 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; - // try { - // const { actionId } = req.params; + try { + const { actionId } = req.params; - // action = await Action.findById(actionId); + action = await Action + .findById(actionId) + .populate([ + 'payload.secretVersions.oldSecretVersion', + 'payload.secretVersions.newSecretVersion' + ]); - - // } catch (err) { + if (!action) throw ActionNotFoundError({ + message: 'Failed to find action' + }); - // } + } catch (err) { + throw ActionNotFoundError({ + message: 'Failed to find action' + }); + } return res.status(200).send({ action diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index cff7f4a98..1df50b18b 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -41,14 +41,23 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { let logs try { const { workspaceId } = req.params; + const { userId, actionNames } = req.query; const offset: number = parseInt(req.query.offset as string); const limit: number = parseInt(req.query.limit as string); - const filters: any = req.query.filters || {}; - filters.workspace = workspaceId; - - logs = await Log.find(filters) + logs = await Log.find({ + workspace: workspaceId, + ...( userId ? { user: userId } : {}), + ...( + actionNames + ? { + actionNames: { + $in: actionNames + } + } : {} + ) + }) .skip(offset) .limit(limit) .populate('actions') diff --git a/backend/src/ee/helpers/action.ts b/backend/src/ee/helpers/action.ts new file mode 100644 index 000000000..2971e3f96 --- /dev/null +++ b/backend/src/ee/helpers/action.ts @@ -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 }; \ No newline at end of file diff --git a/backend/src/ee/helpers/log.ts b/backend/src/ee/helpers/log.ts index a075cc91f..c357c9818 100644 --- a/backend/src/ee/helpers/log.ts +++ b/backend/src/ee/helpers/log.ts @@ -22,6 +22,7 @@ const createLogHelper = async ({ log = await new Log({ user: userId, workspace: workspaceId, + actionNames: actions.map((a) => a.name), actions, channel, ipAddress diff --git a/backend/src/ee/helpers/secret.ts b/backend/src/ee/helpers/secret.ts index a688a108f..8cd59d8d6 100644 --- a/backend/src/ee/helpers/secret.ts +++ b/backend/src/ee/helpers/secret.ts @@ -1,3 +1,4 @@ +import { Types } from 'mongoose'; import * as Sentry from '@sentry/node'; import { Secret @@ -59,16 +60,40 @@ const addSecretVersionsHelper = async ({ }: { secretVersions: ISecretVersion[] }) => { + let newSecretVersions; try { - await SecretVersion.insertMany(secretVersions); + newSecretVersions = await SecretVersion.insertMany(secretVersions); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); 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 { takeSecretSnapshotHelper, - addSecretVersionsHelper + addSecretVersionsHelper, + markDeletedSecretVersionsHelper } \ No newline at end of file diff --git a/backend/src/ee/models/action.ts b/backend/src/ee/models/action.ts index 8ffdfe4bb..3d48aa04d 100644 --- a/backend/src/ee/models/action.ts +++ b/backend/src/ee/models/action.ts @@ -26,8 +26,14 @@ const actionSchema = new Schema( }, payload: { secretVersions: [{ - type: Schema.Types.ObjectId, - ref: 'SecretVersion' + oldSecretVersion: { + type: Schema.Types.ObjectId, + ref: 'SecretVersion' + }, + newSecretVersion: { + type: Schema.Types.ObjectId, + ref: 'SecretVersion' + } }] } }, { diff --git a/backend/src/ee/models/log.ts b/backend/src/ee/models/log.ts index abfadb223..1fdd52710 100644 --- a/backend/src/ee/models/log.ts +++ b/backend/src/ee/models/log.ts @@ -1,9 +1,16 @@ import { Schema, model, Types } from 'mongoose'; +import { + ACTION_ADD_SECRETS, + ACTION_UPDATE_SECRETS, + ACTION_READ_SECRETS, + ACTION_DELETE_SECRETS +} from '../../variables'; export interface ILog { _id: Types.ObjectId; user?: Types.ObjectId; workspace?: Types.ObjectId; + actionNames: string[]; actions: Types.ObjectId[]; channel: string; ipAddress?: string; @@ -19,9 +26,20 @@ const logSchema = new Schema( type: Schema.Types.ObjectId, ref: 'Workspace' }, + actionNames: { + type: [String], + enum: [ + ACTION_ADD_SECRETS, + ACTION_UPDATE_SECRETS, + ACTION_READ_SECRETS, + ACTION_DELETE_SECRETS + ], + required: true + }, actions: [{ type: Schema.Types.ObjectId, - ref: 'Action' + ref: 'Action', + required: true }], channel: { type: String, diff --git a/backend/src/ee/routes/v1/workspace.ts b/backend/src/ee/routes/v1/workspace.ts index 5deb7dd85..e9e6938bf 100644 --- a/backend/src/ee/routes/v1/workspace.ts +++ b/backend/src/ee/routes/v1/workspace.ts @@ -33,7 +33,6 @@ router.get( param('workspaceId').exists().trim(), query('offset').exists().isInt(), query('limit').exists().isInt(), - query('filters').exists(), validateRequest, workspaceController.getWorkspaceLogs ); diff --git a/backend/src/ee/services/EELogService.ts b/backend/src/ee/services/EELogService.ts index 7e6ca4acf..c1b2da6fb 100644 --- a/backend/src/ee/services/EELogService.ts +++ b/backend/src/ee/services/EELogService.ts @@ -1,10 +1,15 @@ +import { Types } from 'mongoose'; import { + Log, Action, IAction } from '../models'; import { createLogHelper } from '../helpers/log'; +import { + createActionSecretHelper +} from '../helpers/action'; import EELicenseService from './EELicenseService'; /** @@ -19,6 +24,7 @@ class EELogService { * @param {Action} obj.actions - actions to include in log * @param {String} obj.channel - channel (web/cli/auto) associated with the log * @param {String} obj.ipAddress - ip address associated with the log + * @returns {Log} log - new audit log */ static async createLog({ userId, @@ -33,7 +39,7 @@ class EELogService { channel: string; ipAddress: string; }) { - if (!EELicenseService.isLicenseValid) return; + if (!EELicenseService.isLicenseValid) return null; return await createLogHelper({ userId, workspaceId, @@ -42,6 +48,34 @@ class EELogService { 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; \ No newline at end of file diff --git a/backend/src/ee/services/EESecretService.ts b/backend/src/ee/services/EESecretService.ts index 643f763f1..64aa81af0 100644 --- a/backend/src/ee/services/EESecretService.ts +++ b/backend/src/ee/services/EESecretService.ts @@ -1,7 +1,9 @@ +import { Types } from 'mongoose'; import { ISecretVersion } from '../models'; import { takeSecretSnapshotHelper, - addSecretVersionsHelper + addSecretVersionsHelper, + markDeletedSecretVersionsHelper } from '../helpers/secret'; 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 {SecretVersion} obj.secretVersions */ @@ -38,10 +40,27 @@ class EESecretService { secretVersions: ISecretVersion[]; }) { if (!EELicenseService.isLicenseValid) return; - await addSecretVersionsHelper({ + return await addSecretVersionsHelper({ 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; \ No newline at end of file diff --git a/backend/src/helpers/database.ts b/backend/src/helpers/database.ts new file mode 100644 index 000000000..7a96e2ab5 --- /dev/null +++ b/backend/src/helpers/database.ts @@ -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 +} \ No newline at end of file diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index 93cbb648a..61fc25174 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -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 * with id [workspaceId] with environment [environment]. Follow steps: @@ -341,42 +344,19 @@ const v1PushSecrets = async ({ await Secret.deleteMany({ _id: { $in: toDelete } }); + + await EESecretService.markDeletedSecretVersions({ + secretIds: toDelete + }); - await SecretVersion.updateMany({ - secret: { $in: toDelete } - }, { - isDeleted: true - }, { - new: true + const deleteAction = await EELogService.createActionSecret({ + name: ACTION_DELETE_SECRETS, + userId, + workspaceId, + secretIds: toDelete }); - // add audit log for deleted secrets - 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); + deleteAction && actions.push(deleteAction); } const toUpdate = oldSecrets @@ -459,10 +439,6 @@ const v1PushSecrets = async ({ secretValueIV, secretValueTag, secretValueHash, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag, - secretCommentHash, } = newSecretsObj[`${s.type}-${s.secretKeyHash}`]; return ({ @@ -481,34 +457,14 @@ const v1PushSecrets = async ({ }) }); - // add audit log for updated secrets - 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({ + const updateAction = await EELogService.createActionSecret({ name: ACTION_UPDATE_SECRETS, - user: new Types.ObjectId(userId), - workspace: new Types.ObjectId(workspaceId), - payload: { - secretVersions: updatedLatestSecretVersions - } - }).save(); + userId, + workspaceId, + secretIds: toUpdate.map((u) => u._id) + }); - actions.push(updateAction); + updateAction && actions.push(updateAction); } // handle adding new secrets @@ -517,45 +473,14 @@ const v1PushSecrets = async ({ if (toAdd.length > 0) { // add secrets const newSecrets = await Secret.insertMany( - toAdd.map(({ - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretKeyHash, - secretValueCiphertext, - secretValueIV, - 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; - }) + toAdd.map((s, idx) => ({ + ...s, + version: 1, + workspace: workspaceId, + type: toAdd[idx].type, + environment, + ...( toAdd[idx].type === 'personal' ? { user: userId } : {}) + })) ); // (EE) add secret versions for new secrets @@ -584,35 +509,14 @@ const v1PushSecrets = async ({ 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, - user: new Types.ObjectId(userId), - workspace: new Types.ObjectId(workspaceId), - payload: { - secretVersions: newLatestSecretVersions - } - }).save(); - - actions.push(addAction); + userId, + workspaceId, + secretIds: newSecrets.map((n) => n._id) + }); + addAction && actions.push(addAction); } // (EE) take a secret snapshot @@ -620,6 +524,7 @@ const v1PushSecrets = async ({ workspaceId }) + // (EE) create (audit) log if (actions.length > 0) { await EELogService.createLog({ 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] * @param {Object} obj * @param {String} obj.userId -id of user to pull secrets for @@ -704,7 +609,7 @@ const pullSecrets = async ({ channel: string; ipAddress: string; }): Promise => { - let secrets: any; // TODO: FIX any + let secrets: any; try { secrets = await getSecrets({ @@ -712,35 +617,15 @@ const pullSecrets = async ({ workspaceId, 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, - user: new Types.ObjectId(userId), - workspace: new Types.ObjectId(workspaceId), - payload: { - secretVersions: readLatestSecretVersions - } - }).save(); + userId, + workspaceId, + secretIds: secrets.map((n: any) => n._id) + }); - await EELogService.createLog({ + readAction && await EELogService.createLog({ userId, workspaceId, actions: [readAction], diff --git a/backend/src/index.ts b/backend/src/index.ts index d182c2655..bc07ec3b9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,12 +4,12 @@ dotenv.config(); import * as Sentry from '@sentry/node'; import { SENTRY_DSN, NODE_ENV, MONGO_URL } from './config'; import { server } from './app'; -import { initDatabase } from './services/database'; +import { DatabaseService } from './services'; import { setUpHealthEndpoint } from './services/health'; import { initSmtp } from './services/smtp'; import { setTransporter } from './helpers/nodemailer'; -initDatabase(MONGO_URL); +DatabaseService.initDatabase(MONGO_URL); setUpHealthEndpoint(server); diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index 3b0aa1862..7ea988c9d 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -52,7 +52,8 @@ const userSchema = new Schema( }, refreshVersion: { type: Number, - default: 0 + default: 0, + select: false } }, { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts new file mode 100644 index 000000000..2e8dc839f --- /dev/null +++ b/backend/src/services/DatabaseService.ts @@ -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; \ No newline at end of file diff --git a/backend/src/services/database.ts b/backend/src/services/database.ts deleted file mode 100644 index 85f39c1b2..000000000 --- a/backend/src/services/database.ts +++ /dev/null @@ -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; -}; diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index 531033f30..c53829922 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -1,9 +1,11 @@ +import DatabaseService from './DatabaseService'; import postHogClient from './PostHogClient'; import BotService from './BotService'; import EventService from './EventService'; import IntegrationService from './IntegrationService'; export { + DatabaseService, postHogClient, BotService, EventService, From 1c2a43ceea987155dd1386dd516be99ecb813840 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 2 Jan 2023 15:24:28 +0700 Subject: [PATCH 15/46] Clean unecessary imports --- backend/src/ee/helpers/secret.ts | 2 +- backend/src/helpers/secret.ts | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/backend/src/ee/helpers/secret.ts b/backend/src/ee/helpers/secret.ts index 0c4172256..2f726a9a5 100644 --- a/backend/src/ee/helpers/secret.ts +++ b/backend/src/ee/helpers/secret.ts @@ -147,7 +147,7 @@ const initSecretVersioningHelper = async () => { } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to ensure secrets are versioned'); + throw new Error('Failed to ensure that secrets are versioned'); } } diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index 61fc25174..f71f094e8 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -1,5 +1,4 @@ import * as Sentry from '@sentry/node'; -import { Types } from 'mongoose'; import { Secret, ISecret, @@ -10,13 +9,8 @@ import { } from '../ee/services'; import { SecretVersion, - Action, IAction } from '../ee/models'; -import { - takeSecretSnapshotHelper -} from '../ee/helpers/secret'; -import { decryptSymmetric } from '../utils/crypto'; import { SECRET_SHARED, SECRET_PERSONAL, @@ -62,8 +56,6 @@ interface Update { [index: string]: any; } -type DecryptSecretType = 'text' | 'object' | 'expanded'; - /** * Push secrets for user with id [userId] to workspace * with id [workspaceId] with environment [environment]. Follow steps: @@ -285,9 +277,6 @@ 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 * with id [workspaceId] with environment [environment]. Follow steps: From 4af839040e42b995b240768e6768bf7a3cff274b Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 2 Jan 2023 15:43:21 +0700 Subject: [PATCH 16/46] Patch actionNames on getWorkspacelogs --- backend/src/ee/controllers/v1/workspaceController.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 1df50b18b..838a3253d 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -41,10 +41,11 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { let logs try { const { workspaceId } = req.params; - const { userId, actionNames } = req.query; const offset: number = parseInt(req.query.offset as string); const limit: number = parseInt(req.query.limit as string); + const userId: string = req.query.userId as string; + const actionNames: string = req.query.actionNames as string; logs = await Log.find({ workspace: workspaceId, @@ -53,7 +54,7 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { actionNames ? { actionNames: { - $in: actionNames + $in: actionNames.split(',') } } : {} ) From 03b7d3a5ce4cc8e54a588e6aa99d25f1129afb33 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Mon, 2 Jan 2023 09:57:02 -0800 Subject: [PATCH 17/46] Wired frontend for logs --- .../ee/controllers/v1/workspaceController.ts | 1 + frontend/components/basic/EventFilter.tsx | 5 +-- frontend/ee/api/secrets/GetProjectLogs.ts | 41 +++++++++++++++---- frontend/pages/activity/[id].tsx | 35 ++++++++++++---- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 838a3253d..4fe040122 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -59,6 +59,7 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { } : {} ) }) + .sort({ createdAt: -1 }) .skip(offset) .limit(limit) .populate('actions') diff --git a/frontend/components/basic/EventFilter.tsx b/frontend/components/basic/EventFilter.tsx index d4fe36193..fc0c4e1e1 100644 --- a/frontend/components/basic/EventFilter.tsx +++ b/frontend/components/basic/EventFilter.tsx @@ -48,7 +48,7 @@ export default function EventFilter({
{selected != '' ? ( -

{selected}

+

{t("activity:event." + selected)}

) : (

Select an event

)} @@ -76,7 +76,7 @@ export default function EventFilter({ className={`px-4 h-10 flex items-center text-sm cursor-pointer hover:bg-mineshaft-700 text-bunker-200 rounded-md ${ selected == t("activity:event." + event.name) && 'bg-mineshaft-700' }`} - value={t("activity:event." + event.name)} + value={event.name} > {({ selected }) => ( <> @@ -90,7 +90,6 @@ export default function EventFilter({ )} - {/* {event.name} */} ); })} diff --git a/frontend/ee/api/secrets/GetProjectLogs.ts b/frontend/ee/api/secrets/GetProjectLogs.ts index d5324355c..277205f2f 100644 --- a/frontend/ee/api/secrets/GetProjectLogs.ts +++ b/frontend/ee/api/secrets/GetProjectLogs.ts @@ -5,7 +5,8 @@ interface workspaceProps { workspaceId: string; offset: number; limit: number; - filters: object; + userId: string; + actionNames: string; } /** @@ -14,17 +15,41 @@ interface workspaceProps { * @param {string} obj.workspaceId - workspace id for which we are trying to get project log * @param {object} obj.offset - teh starting point of logs that we want to pull * @param {object} obj.limit - how many logs will we output - * @param {object} obj.filters + * @param {object} obj.userId - optional userId filter - will only query logs for that user + * @param {string} obj.actionNames - optional actionNames filter - will only query logs for those actions * @returns */ -const getProjectLogs = async ({ workspaceId, offset, limit, filters }: workspaceProps) => { +const getProjectLogs = async ({ workspaceId, offset, limit, userId, actionNames }: workspaceProps) => { + let payload; + if (userId != "" && actionNames != '') { + payload = { + offset: String(offset), + limit: String(limit), + userId: JSON.stringify(userId), + actionNames: actionNames + } + } else if (userId != "") { + payload = { + offset: String(offset), + limit: String(limit), + userId: JSON.stringify(userId) + } + } else if (actionNames != "") { + payload = { + offset: String(offset), + limit: String(limit), + actionNames: actionNames + } + } else { + payload = { + offset: String(offset), + limit: String(limit) + } + } + return SecurityClient.fetchCall( '/api/v1/workspace/' + workspaceId + '/logs?' + - new URLSearchParams({ - offset: String(offset), - limit: String(limit), - filters: JSON.stringify(filters) - }), + new URLSearchParams(payload), { method: 'GET', headers: { diff --git a/frontend/pages/activity/[id].tsx b/frontend/pages/activity/[id].tsx index f384ae69f..ed5a6def0 100644 --- a/frontend/pages/activity/[id].tsx +++ b/frontend/pages/activity/[id].tsx @@ -55,9 +55,34 @@ export default function Activity() { const [currentEvent, setCurrentEvent] = useState(""); const { t } = useTranslation(); + // this use effect updates the data in case of a new filter being added + useEffect(() => { + setCurrentOffset(0); + const getLogData = async () => { + const tempLogsData = await getProjectLogs({ workspaceId: String(router.query.id), offset: 0, limit: currentLimit, userId: "", actionNames: eventChosen }) + setLogsData(tempLogsData.map((log: logData) => { + return { + _id: log._id, + channel: log.channel, + createdAt: log.createdAt, + ipAddress: log.ipAddress, + user: log.user.email, + payload: log.actions.map(action => { + return { + name: action.name, + secretVersions: action.payload.secretVersions + } + }) + } + })) + } + getLogData(); + }, [eventChosen]); + + // this use effect adds more data in case 'View More' button is clicked useEffect(() => { const getLogData = async () => { - const tempLogsData = await getProjectLogs({ workspaceId: String(router.query.id), offset: currentOffset, limit: currentLimit, filters: {} }) + const tempLogsData = await getProjectLogs({ workspaceId: String(router.query.id), offset: currentOffset, limit: currentLimit, userId: "", actionNames: eventChosen }) setLogsData(logsData.concat(tempLogsData.map((log: logData) => { return { _id: log._id, @@ -97,16 +122,10 @@ export default function Activity() {
b.createdAt.localeCompare(a.createdAt)) - .filter((log) => - eventChosen != '' ? log.payload?.map(action => t("activity:event." + action.name)).includes(eventChosen) : true - ) - } + data={logsData} toggleSidebar={toggleSidebar} setCurrentEvent={setCurrentEvent} /> From ae5320e4fa8728d831931c14fc68572c51246d65 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Mon, 2 Jan 2023 14:20:39 -0800 Subject: [PATCH 18/46] Finished activity logs V1 --- frontend/components/basic/EventFilter.tsx | 5 + frontend/ee/api/secrets/GetActionData.ts | 32 ++++ frontend/ee/components/ActivitySideBar.tsx | 207 ++++++++++++++++----- frontend/ee/components/ActivityTable.tsx | 43 ++--- frontend/pages/activity/[id].tsx | 12 +- 5 files changed, 220 insertions(+), 79 deletions(-) create mode 100644 frontend/ee/api/secrets/GetActionData.ts diff --git a/frontend/components/basic/EventFilter.tsx b/frontend/components/basic/EventFilter.tsx index fc0c4e1e1..c9b31fd43 100644 --- a/frontend/components/basic/EventFilter.tsx +++ b/frontend/components/basic/EventFilter.tsx @@ -6,6 +6,7 @@ import { faEye, faPlus, faShuffle, + faTrash, faX } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -28,6 +29,10 @@ const eventOptions = [ { name: 'updateSecrets', icon: faShuffle + }, + { + name: 'deleteSecrets', + icon: faTrash } ]; diff --git a/frontend/ee/api/secrets/GetActionData.ts b/frontend/ee/api/secrets/GetActionData.ts new file mode 100644 index 000000000..122870d69 --- /dev/null +++ b/frontend/ee/api/secrets/GetActionData.ts @@ -0,0 +1,32 @@ +import SecurityClient from '~/utilities/SecurityClient'; + + +interface workspaceProps { + actionId: string; +} + +/** + * This function fetches the data for a certain action performed by a user + * @param {object} obj + * @param {string} obj.actionId - id of an action for which we are trying to get data + * @returns + */ +const getActionData = async ({ actionId }: workspaceProps) => { + return SecurityClient.fetchCall( + '/api/v1/action/' + actionId, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + ).then(async (res) => { + console.log(188, res) + if (res && res.status == 200) { + return (await res.json()).action; + } else { + console.log('Failed to get the info about an action'); + } + }); +}; + +export default getActionData; diff --git a/frontend/ee/components/ActivitySideBar.tsx b/frontend/ee/components/ActivitySideBar.tsx index d96a3f6ab..e0a634d45 100644 --- a/frontend/ee/components/ActivitySideBar.tsx +++ b/frontend/ee/components/ActivitySideBar.tsx @@ -1,79 +1,184 @@ +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { useRouter } from "next/router"; import { useTranslation } from "next-i18next"; import { faX } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import getActionData from "ee/api/secrets/GetActionData"; import patienceDiff from 'ee/utilities/findTextDifferences'; +import getLatestFileKey from "~/pages/api/workspace/getLatestFileKey"; + import DashboardInputField from '../../components/dashboard/DashboardInputField'; -const secretChanges = [{ - "oldSecret": "secret1", - "newSecret": "ecret2" -}, { - "oldSecret": "secret1", - "newSecret": "sercet2" -}, { - "oldSecret": "localhosta:8080", - "newSecret": "aaaalocalhoats:3000" -}] +const { + decryptAssymmetric, + decryptSymmetric +} = require('../../components/utilities/cryptography/crypto'); +const nacl = require('tweetnacl'); +nacl.util = require('tweetnacl-util'); interface SideBarProps { - toggleSidebar: (value: string[]) => void; - sidebarData: string[]; - currentEvent: string; + toggleSidebar: (value: string) => void; + currentAction: string; +} + +interface SecretProps { + secret: string; + secretKeyCiphertext: string; + secretKeyHash: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueHash: string; + secretValueIV: string; + secretValueTag: string; +} + +interface DecryptedSecretProps { + newSecretVersion: { + key: string; + value: string; + } + oldSecretVersion: { + key: string; + value: string; + } +} + +interface ActionProps { + name: string; } /** * @param {object} obj * @param {function} obj.toggleSidebar - function that opens or closes the sidebar - * @param {string[]} obj.secretIds - data of payload - * @param {string} obj.currentEvent - the event name for which a sidebar is being displayed + * @param {string} obj.currentAction - the action id for which a sidebar is being displayed * @returns the sidebar with the payload of user activity logs */ const ActivitySideBar = ({ toggleSidebar, - sidebarData, - currentEvent + currentAction }: SideBarProps) => { const { t } = useTranslation(); + const router = useRouter(); + const [actionData, setActionData] = useState(); + const [actionMetaData, setActionMetaData] = useState(); + const [isLoading, setIsLoading] = useState(false); - return
-
-
-

{t("activity:event." + currentEvent)}

-
toggleSidebar([])}> - + useEffect(() => { + const getLogData = async () => { + setIsLoading(true); + const tempActionData = await getActionData({ actionId: currentAction }); + const latestKey = await getLatestFileKey({ workspaceId: String(router.query.id) }) + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); + + // #TODO: make this a separate function and reuse across the app + let decryptedLatestKey: string; + if (latestKey) { + // assymmetrically decrypt symmetric key with local private key + decryptedLatestKey = decryptAssymmetric({ + ciphertext: latestKey.latestKey.encryptedKey, + nonce: latestKey.latestKey.nonce, + publicKey: latestKey.latestKey.sender.publicKey, + privateKey: String(PRIVATE_KEY) + }); + } + + const decryptedSecretVersions = tempActionData.payload.secretVersions.map((encryptedSecretVersion: { + newSecretVersion?: SecretProps; + oldSecretVersion?: SecretProps; + }) => { + return { + newSecretVersion: { + key: decryptSymmetric({ + ciphertext: encryptedSecretVersion.newSecretVersion!.secretKeyCiphertext, + iv: encryptedSecretVersion.newSecretVersion!.secretKeyIV, + tag: encryptedSecretVersion.newSecretVersion!.secretKeyTag, + key: decryptedLatestKey + }), + value: decryptSymmetric({ + ciphertext: encryptedSecretVersion.newSecretVersion!.secretValueCiphertext, + iv: encryptedSecretVersion.newSecretVersion!.secretValueIV, + tag: encryptedSecretVersion.newSecretVersion!.secretValueTag, + key: decryptedLatestKey + }) + }, + oldSecretVersion: { + key: encryptedSecretVersion.oldSecretVersion?.secretKeyCiphertext + ? decryptSymmetric({ + ciphertext: encryptedSecretVersion.oldSecretVersion?.secretKeyCiphertext, + iv: encryptedSecretVersion.oldSecretVersion?.secretKeyIV, + tag: encryptedSecretVersion.oldSecretVersion?.secretKeyTag, + key: decryptedLatestKey + }): undefined, + value: encryptedSecretVersion.oldSecretVersion?.secretValueCiphertext + ? decryptSymmetric({ + ciphertext: encryptedSecretVersion.oldSecretVersion?.secretValueCiphertext, + iv: encryptedSecretVersion.oldSecretVersion?.secretValueIV, + tag: encryptedSecretVersion.oldSecretVersion?.secretValueTag, + key: decryptedLatestKey + }): undefined + } + } + }) + + setActionData(decryptedSecretVersions); + setActionMetaData({name: tempActionData.name}); + setIsLoading(false); + } + getLogData(); + }, [currentAction]); + + return
+ {isLoading ? ( +
+ infisical loading indicator +
+ ) : ( +
+
+

{t("activity:event." + actionMetaData?.name)}

+
toggleSidebar("")}> + +
+
+
+ {(actionMetaData?.name == 'readSecrets' + || actionMetaData?.name == 'addSecrets' + || actionMetaData?.name == 'deleteSecrets') && actionData?.map((item, id) => +
+
{item.newSecretVersion.key}
+ {}} + type="value" + position={1} + value={item.newSecretVersion.value} + isDuplicate={false} + blurred={false} + /> +
+ )} + {actionMetaData?.name == 'updateSecrets' && actionData?.map((item, id) => + <> +
{item.newSecretVersion.key}
+
+
- {patienceDiff(item.oldSecretVersion.value.split(''), item.newSecretVersion.value.split(''), false).lines.map((character, id) => character.bIndex != -1 && {character.line})}
+
+ {patienceDiff(item.oldSecretVersion.value.split(''), item.newSecretVersion.value.split(''), false).lines.map((character, id) => character.aIndex != -1 && {character.line})}
+
+ + )}
-
- {currentEvent == 'readSecrets' && sidebarData.map((item, id) => - <> -
Key {id}
- {}} - type="varName" - position={1} - value={"a" + item} - isDuplicate={false} - blurred={false} - /> - - )} - {currentEvent == 'updateSecrets' && sidebarData.map((item, id) => - secretChanges.map(secretChange => - <> -
Secret Name {id}
-
-
- {patienceDiff(secretChange.oldSecret.split(''), secretChange.newSecret.split(''), false).lines.map((character, id) => character.aIndex != -1 && {character.line})}
-
+ {patienceDiff(secretChange.oldSecret.split(''), secretChange.newSecret.split('')).lines.map((character, id) => character.bIndex != -1 && {character.line})}
-
- - ))} -
-
- + )}
}; diff --git a/frontend/ee/components/ActivityTable.tsx b/frontend/ee/components/ActivityTable.tsx index a25920202..607b25c5d 100644 --- a/frontend/ee/components/ActivityTable.tsx +++ b/frontend/ee/components/ActivityTable.tsx @@ -4,8 +4,7 @@ import { useTranslation } from "next-i18next"; import { faAngleDown, faAngleRight, - faUpRightFromSquare, - faX + faUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import timeSince from 'ee/utilities/timeSince'; @@ -14,6 +13,7 @@ import guidGenerator from '../../components/utilities/randomId'; interface PayloadProps { + _id: string; name: string; secretVersions: string[]; } @@ -29,25 +29,26 @@ interface logData { /** - * + * This is a single row of the activity table * @param obj - * @param {function} obj.setCurrentEvent - specify the name of the event for which the sidebar is being opened + * @param {logData} obj.row - data for a certain event + * @param {function} obj.toggleSidebar - open and close sidebar that displays data for a specific event * @returns */ -const ActivityLogsRow = ({ row, toggleSidebar, setCurrentEvent }: { row: logData, toggleSidebar: (value: string[]) => void; setCurrentEvent: (value: string) => void; }) => { +const ActivityLogsRow = ({ row, toggleSidebar }: { row: logData, toggleSidebar: (value: string) => void; }) => { const [payloadOpened, setPayloadOpened] = useState(false); const { t } = useTranslation(); return ( <> - + setPayloadOpened(!payloadOpened)} className="border-mineshaft-700 border-t text-gray-300 flex items-center cursor-pointer" > @@ -66,26 +67,23 @@ const ActivityLogsRow = ({ row, toggleSidebar, setCurrentEvent }: { row: logData {payloadOpened && - + Timestamp {row.createdAt} } {payloadOpened && row.payload?.map((action, index) => - + {t("activity:event." + action.name)} - { - toggleSidebar(action.secretVersions); - setCurrentEvent(action.name); - }}> + toggleSidebar(action._id)}> {action.secretVersions.length + (action.secretVersions.length != 1 ? " secrets" : " secret")} )} {payloadOpened && - + IP Address {row.ipAddress} @@ -99,28 +97,27 @@ const ActivityLogsRow = ({ row, toggleSidebar, setCurrentEvent }: { row: logData * @param {object} obj * @param {logData} obj.data - data for user activity logs * @param {function} obj.toggleSidebar - function that opens or closes the sidebar - * @param {function} obj.setCurrentEvent - specify the name of the event for which the sidebar is being opened * @returns */ -const ActivityTable = ({ data, toggleSidebar, setCurrentEvent }: { data: logData[], toggleSidebar: (value: string[]) => void; setCurrentEvent: (value: string) => void; }) => { +const ActivityTable = ({ data, toggleSidebar }: { data: logData[], toggleSidebar: (value: string) => void; }) => { return (
-
+
- + - - - - + + + + {data?.map((row, index) => { - return ; + return ; })}
EventUserSourceTimeEVENTUSERSOURCETIME
diff --git a/frontend/pages/activity/[id].tsx b/frontend/pages/activity/[id].tsx index ed5a6def0..974607d0a 100644 --- a/frontend/pages/activity/[id].tsx +++ b/frontend/pages/activity/[id].tsx @@ -21,6 +21,7 @@ interface logData { email: string; }; actions: { + _id: string; name: string; payload: { secretVersions: string[]; @@ -29,6 +30,7 @@ interface logData { } interface PayloadProps { + _id: string; name: string; secretVersions: string[]; } @@ -51,8 +53,7 @@ export default function Activity() { const [logsData, setLogsData] = useState([]); const [currentOffset, setCurrentOffset] = useState(0); const currentLimit = 10; - const [sidebarData, toggleSidebar] = useState([]) - const [currentEvent, setCurrentEvent] = useState(""); + const [currentSidebarAction, toggleSidebar] = useState() const { t } = useTranslation(); // this use effect updates the data in case of a new filter being added @@ -69,6 +70,7 @@ export default function Activity() { user: log.user.email, payload: log.actions.map(action => { return { + _id: action._id, name: action.name, secretVersions: action.payload.secretVersions } @@ -92,6 +94,7 @@ export default function Activity() { user: log.user.email, payload: log.actions.map(action => { return { + _id: action._id, name: action.name, secretVersions: action.payload.secretVersions } @@ -109,13 +112,13 @@ export default function Activity() { return (
- {sidebarData.length > 0 && } + {currentSidebarAction && }

Activity Logs

- Event history limited to the last 12 months. + Event history for this Infisical project.

@@ -127,7 +130,6 @@ export default function Activity() {
From 0ff8194cf8ec636711a49a5ec8d81d0a357379f2 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 3 Jan 2023 09:19:07 +0700 Subject: [PATCH 19/46] Modify getWorkspaceLogs to accept sortBy query param --- backend/src/ee/controllers/v1/workspaceController.ts | 9 ++++++++- backend/src/ee/routes/v1/workspace.ts | 3 +++ frontend/ee/api/secrets/GetProjectLogs.ts | 6 +++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 4fe040122..25a8c8d76 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -37,6 +37,12 @@ import { }); } +/** + * Return (audit) logs for workspace with id [workspaceId] + * @param req + * @param res + * @returns + */ export const getWorkspaceLogs = async (req: Request, res: Response) => { let logs try { @@ -44,6 +50,7 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { const offset: number = parseInt(req.query.offset as string); const limit: number = parseInt(req.query.limit as string); + const sortBy: string = req.query.sortBy as string; const userId: string = req.query.userId as string; const actionNames: string = req.query.actionNames as string; @@ -59,7 +66,7 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { } : {} ) }) - .sort({ createdAt: -1 }) + .sort({ createdAt: sortBy === 'recent' ? -1 : 1 }) .skip(offset) .limit(limit) .populate('actions') diff --git a/backend/src/ee/routes/v1/workspace.ts b/backend/src/ee/routes/v1/workspace.ts index e9e6938bf..5ae190ccd 100644 --- a/backend/src/ee/routes/v1/workspace.ts +++ b/backend/src/ee/routes/v1/workspace.ts @@ -33,6 +33,9 @@ router.get( param('workspaceId').exists().trim(), query('offset').exists().isInt(), query('limit').exists().isInt(), + query('sortBy'), + query('userId'), + query('actionNames'), validateRequest, workspaceController.getWorkspaceLogs ); diff --git a/frontend/ee/api/secrets/GetProjectLogs.ts b/frontend/ee/api/secrets/GetProjectLogs.ts index 277205f2f..e127be815 100644 --- a/frontend/ee/api/secrets/GetProjectLogs.ts +++ b/frontend/ee/api/secrets/GetProjectLogs.ts @@ -25,6 +25,7 @@ const getProjectLogs = async ({ workspaceId, offset, limit, userId, actionNames payload = { offset: String(offset), limit: String(limit), + sortBy: 'recent', userId: JSON.stringify(userId), actionNames: actionNames } @@ -32,18 +33,21 @@ const getProjectLogs = async ({ workspaceId, offset, limit, userId, actionNames payload = { offset: String(offset), limit: String(limit), + sortBy: 'recent', userId: JSON.stringify(userId) } } else if (actionNames != "") { payload = { offset: String(offset), limit: String(limit), + sortBy: 'recent', actionNames: actionNames } } else { payload = { offset: String(offset), - limit: String(limit) + limit: String(limit), + sortBy: 'recent' } } From 6845e9129ae71812411f191bff0e9f95fc5488b4 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Mon, 2 Jan 2023 18:33:24 -0800 Subject: [PATCH 20/46] Updated icon for activity logs --- frontend/components/basic/Layout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/components/basic/Layout.tsx b/frontend/components/basic/Layout.tsx index a53de0b41..8bdabc97b 100644 --- a/frontend/components/basic/Layout.tsx +++ b/frontend/components/basic/Layout.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/router"; import { useTranslation } from "next-i18next"; import { faBookOpen, + faFileLines, faGear, faKey, faMobile, @@ -158,7 +159,7 @@ export default function Layout({ children }: LayoutProps) { { href: '/activity/' + workspaceMapping[workspaceSelected as any], title: 'Activity Logs', - emoji: + emoji: }, { href: "/settings/project/" + workspaceMapping[workspaceSelected as any], From d0949b2e196c8d0764d3f331b559d0b05284574c Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Mon, 2 Jan 2023 18:53:56 -0800 Subject: [PATCH 21/46] Fixed the sorting buf with version history --- .../src/ee/controllers/v1/secretController.ts | 1 + frontend/ee/components/SecretVersionList.tsx | 67 ++++++++++++------- 2 files changed, 42 insertions(+), 26 deletions(-) diff --git a/backend/src/ee/controllers/v1/secretController.ts b/backend/src/ee/controllers/v1/secretController.ts index b2d66ab33..a2d68ca96 100644 --- a/backend/src/ee/controllers/v1/secretController.ts +++ b/backend/src/ee/controllers/v1/secretController.ts @@ -18,6 +18,7 @@ import { SecretVersion } from '../../models'; secretVersions = await SecretVersion.find({ secret: secretId }) + .sort({ createdAt: -1 }) .skip(offset) .limit(limit); diff --git a/frontend/ee/components/SecretVersionList.tsx b/frontend/ee/components/SecretVersionList.tsx index 6cf7bd9b2..3eb40005f 100644 --- a/frontend/ee/components/SecretVersionList.tsx +++ b/frontend/ee/components/SecretVersionList.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import Image from 'next/image'; import { useRouter } from 'next/router'; import { useTranslation } from "next-i18next"; import { faCircle, faDotCircle } from '@fortawesome/free-solid-svg-icons'; @@ -22,15 +23,18 @@ interface EncrypetedSecretVersionListProps { /** + * @param {string} secretId - the id of a secret for which are querying version history * @returns a list of versions for a specific secret */ const SecretVersionList = ({ secretId }: { secretId: string; }) => { const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); - const [secretVersions, setSecretVersions] = useState([{createdAt: "123", value: "124"}]); + const [secretVersions, setSecretVersions] = useState([]); useEffect(() => { const getSecretVersionHistory = async () => { + setIsLoading(true); try { const encryptedSecretVersions = await getSecretVersions({ secretId, offset: 0, limit: 10}); const latestKey = await getLatestFileKey({ workspaceId: String(router.query.id) }) @@ -61,43 +65,54 @@ const SecretVersionList = ({ secretId }: { secretId: string; }) => { }) setSecretVersions(decryptedSecretVersions); + setIsLoading(false); } catch (error) { console.log(error) } }; getSecretVersionHistory(); - }, []); + }, [secretId]); return
-

{t("dashboard:sidebar.version-history")}

-
-
- {secretVersions?.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .map((version: DecryptedSecretVersionListProps, index: number) => -
-
-
-
-
-
-
- {(new Date(version.createdAt)).toLocaleDateString('en-US', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - })} +

{t("dashboard:sidebar.version-history")}

+
+ {isLoading ? ( +
+ infisical loading indicator +
+ ) : ( +
+ {secretVersions?.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .map((version: DecryptedSecretVersionListProps, index: number) => +
+
+
+
+
+
+
+ {(new Date(version.createdAt)).toLocaleDateString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + })} +
+

Value:{version.value}

+
-

Value:{version.value}

- {/*

Updated by:{version.user}

*/} -
+ )}
)}
-
}; export default SecretVersionList; From 679db32de95634ffdde3d440725d608c03c1eef6 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 3 Jan 2023 10:49:58 +0700 Subject: [PATCH 22/46] Begin docs for secret versioning, snapshots, and audit logs --- docs/getting-started/dashboard/audit-logs.mdx | 9 +++++++++ docs/getting-started/dashboard/integrations.mdx | 7 +++---- docs/getting-started/dashboard/pit-recovery.mdx | 5 +++++ docs/getting-started/dashboard/versioning.mdx | 5 +++++ docs/getting-started/features.mdx | 14 +++++++------- docs/mint.json | 3 +++ 6 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 docs/getting-started/dashboard/audit-logs.mdx create mode 100644 docs/getting-started/dashboard/pit-recovery.mdx create mode 100644 docs/getting-started/dashboard/versioning.mdx diff --git a/docs/getting-started/dashboard/audit-logs.mdx b/docs/getting-started/dashboard/audit-logs.mdx new file mode 100644 index 000000000..bb31423b4 --- /dev/null +++ b/docs/getting-started/dashboard/audit-logs.mdx @@ -0,0 +1,9 @@ +--- +title: "Activity Logs" +--- + +Activity logs record all actions going through Infisical including CRUD operations applied to environment variables. They help answer questions like: + +- Who added or updated environment variables recently? +- Did Bob read environment variables last week (if at all)? +- What IP address was used for that action? diff --git a/docs/getting-started/dashboard/integrations.mdx b/docs/getting-started/dashboard/integrations.mdx index de25fa861..ce2904e38 100644 --- a/docs/getting-started/dashboard/integrations.mdx +++ b/docs/getting-started/dashboard/integrations.mdx @@ -4,11 +4,10 @@ title: "Integrations" Integrations allow environment variables to be synced across your entire infrastructure from local development to CI/CD and production. -We're still early with integrations, but expect more soon. +We're still early with integrations, but expect more soon. - - View all available integrations and their guide + + View all available integrations and their guides ![integrations](../../images/project-integrations.png) - diff --git a/docs/getting-started/dashboard/pit-recovery.mdx b/docs/getting-started/dashboard/pit-recovery.mdx new file mode 100644 index 000000000..534cc2718 --- /dev/null +++ b/docs/getting-started/dashboard/pit-recovery.mdx @@ -0,0 +1,5 @@ +--- +title: "Point-in-Time Recovery" +--- + +Point-in-time (PIT) recovery allows environment variables to be rolled back to any point in time. It's powered by snapshots that get captured after mutations to environment variables. diff --git a/docs/getting-started/dashboard/versioning.mdx b/docs/getting-started/dashboard/versioning.mdx new file mode 100644 index 000000000..3a6ba2e2c --- /dev/null +++ b/docs/getting-started/dashboard/versioning.mdx @@ -0,0 +1,5 @@ +--- +title: "Secret Versioning" +--- + +Secret versioning allows an individual environment variable to be rolled back without touching other project environment variables. diff --git a/docs/getting-started/features.mdx b/docs/getting-started/features.mdx index c520dd905..0205f0a46 100644 --- a/docs/getting-started/features.mdx +++ b/docs/getting-started/features.mdx @@ -4,14 +4,14 @@ title: "Features" This is a non-exhaustive list of features that Infisical offers: -## Web UI +## Platform -The Web UI is used to manage teams and environment variables. - -- Provision access to organizations and projects. -- Add/delete/update, scope, search, sort, hide-unhide environment variables. -- Separate environment variables by environment. -- Import environment variables via drag-and-drop, export them as a .env file. +- Provision members access to organizations and projects. +- Manage secrets by adding, deleting, updating them across environments; search, sort, hide/un-hide, export/import them. +- Sync secrets to platforms via integrations to platforms like GitHub, Vercel, and Netlify. +- Rollback secrets to any point in time. +- Rollback each secrets to any version. +- Track actions through activity logs. ## CLI diff --git a/docs/mint.json b/docs/mint.json index e94b70a6b..bba08787c 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -80,6 +80,9 @@ "getting-started/dashboard/organization", "getting-started/dashboard/project", "getting-started/dashboard/integrations", + "getting-started/dashboard/pit-recovery", + "getting-started/dashboard/versioning", + "getting-started/dashboard/audit-logs", "getting-started/dashboard/token" ] }, From fe0c46652355c5ce3b8ff5cabefe310f9f104e4d Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Mon, 2 Jan 2023 20:17:16 -0800 Subject: [PATCH 23/46] Moved the delete button to the sidebar --- .../dashboard/DashboardInputField.tsx | 10 +- frontend/components/dashboard/KeyPair.tsx | 20 +-- frontend/components/dashboard/SideBar.tsx | 159 ++++++++++-------- frontend/pages/dashboard/[id].tsx | 15 +- 4 files changed, 106 insertions(+), 98 deletions(-) diff --git a/frontend/components/dashboard/DashboardInputField.tsx b/frontend/components/dashboard/DashboardInputField.tsx index 29761bf07..a667c39d0 100644 --- a/frontend/components/dashboard/DashboardInputField.tsx +++ b/frontend/components/dashboard/DashboardInputField.tsx @@ -53,7 +53,7 @@ const DashboardInputField = ({ return (
@@ -85,7 +85,7 @@ const DashboardInputField = ({ return (
{override == true &&
Override enabled
} - {value.split(REGEX).map((word, id) => { + {value?.split(REGEX).map((word, id) => { if (word.match(REGEX) !== null) { return ( @@ -139,7 +139,7 @@ const DashboardInputField = ({ })}
{blurred && ( -
+
{value.split('').map(() => ( void; modifyKey: (value: string, position: number) => void; modifyValue: (value: string, position: number) => void; isBlurred: boolean; @@ -28,7 +27,6 @@ interface KeyPairProps { * This component represent a single row for an environemnt variable on the dashboard * @param {object} obj * @param {String[]} obj.keyPair - data related to the environment variable (id, pos, key, value, public/private) - * @param {function} obj.deleteRow - a function to delete a certain keyPair * @param {function} obj.modifyKey - modify the key of a certain environment variable * @param {function} obj.modifyValue - modify the value of a certain environment variable * @param {boolean} obj.isBlurred - if the blurring setting is turned on @@ -39,7 +37,6 @@ interface KeyPairProps { */ const KeyPair = ({ keyPair, - deleteRow, modifyKey, modifyValue, isBlurred, @@ -57,7 +54,7 @@ const KeyPair = ({
}
-
+
-
-
+
+
-
toggleSidebar(keyPair.id)} className="cursor-pointer w-9 h-9 bg-mineshaft-700 hover:bg-chicago-700 rounded-md flex flex-row justify-center items-center duration-200"> +
toggleSidebar(keyPair.id)} className="cursor-pointer w-[2.35rem] h-[2.35rem] bg-mineshaft-700 hover:bg-chicago-700 rounded-md flex flex-row justify-center items-center duration-200">
-
-
-
); diff --git a/frontend/components/dashboard/SideBar.tsx b/frontend/components/dashboard/SideBar.tsx index 0a9fca6a1..41ac9c144 100644 --- a/frontend/components/dashboard/SideBar.tsx +++ b/frontend/components/dashboard/SideBar.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import Image from 'next/image'; import { useTranslation } from "next-i18next"; import { faX } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -40,6 +41,7 @@ interface SideBarProps { savePush: () => void; sharedToHide: string[]; setSharedToHide: (values: string[]) => void; + deleteRow: any; } /** @@ -54,6 +56,7 @@ interface SideBarProps { * @param {function} obj.savePush - save changes andp ush secrets * @param {string[]} obj.sharedToHide - an array of shared secrets that we want to hide visually because they are overriden. * @param {function} obj.setSharedToHide - a function that updates the array of secrets that we want to hide visually + * @param {function} obj.deleteRow - a function to delete a certain keyPair * @returns the sidebar with 'secret's settings' */ const SideBar = ({ @@ -67,93 +70,97 @@ const SideBar = ({ buttonReady, savePush, sharedToHide, - setSharedToHide + setSharedToHide, + deleteRow }: SideBarProps) => { + const [isLoading, setIsLoading] = useState(false); const [overrideEnabled, setOverrideEnabled] = useState(data.map(secret => secret.type).includes("personal")); const { t } = useTranslation(); return
-
-
-

{t("dashboard:sidebar.secret")}

-
toggleSidebar("None")}> - + {isLoading ? ( +
+ infisical loading indicator +
+ ) : ( +
+
+

{t("dashboard:sidebar.secret")}

+
toggleSidebar("None")}> + +
-
-
-

{t("dashboard:sidebar.key")}

- -
- {data.filter(secret => secret.type == "shared")[0]?.value - ?
-

{t("dashboard:sidebar.value")}

- secret.type == "shared")[0]?.pos} - value={data.filter(secret => secret.type == "shared")[0]?.value} - isDuplicate={false} - blurred={true} - /> -
- secret.type == "shared")[0]?.pos} /> -
-
- :
- {t("common:note")}: - {t("dashboard:sidebar.personal-explanation")} -
} -
- {data.filter(secret => secret.type == "shared")[0]?.value && -
-

{t("dashboard:sidebar.override")}

- +

{t("dashboard:sidebar.key")}

+ -
} -
+
+ {data.filter(secret => secret.type == "shared")[0]?.value + ?
+

{t("dashboard:sidebar.value")}

secret.type == "personal")[0]?.pos : data[0]?.pos} - value={overrideEnabled ? data.filter(secret => secret.type == "personal")[0]?.value : data[0]?.value} + position={data.filter(secret => secret.type == "shared")[0]?.pos} + value={data.filter(secret => secret.type == "shared")[0]?.value} isDuplicate={false} - blurred={true} + blurred={true} /> -
- secret.type == "personal")[0]?.pos : data[0]?.pos} /> +
+ secret.type == "shared")[0]?.pos} />
+ :
+ {t("common:note")}: + {t("dashboard:sidebar.personal-explanation")} +
} +
+ {data.filter(secret => secret.type == "shared")[0]?.value && +
+

{t("dashboard:sidebar.override")}

+ +
} +
+ secret.type == "personal")[0]?.pos : data[0]?.pos} + value={overrideEnabled ? data.filter(secret => secret.type == "personal")[0]?.value : data[0]?.value} + isDuplicate={false} + blurred={true} + /> +
+ secret.type == "personal")[0]?.pos : data[0]?.pos} /> +
+
+
+ + secret.type == "shared")[0]?.comment} modifyComment={modifyComment} position={data[0]?.pos} />
- {/*
-

Group

- {}} - data={["Group1"]} - isFull={true} - /> -
*/} - - secret.type == "shared")[0]?.comment} modifyComment={modifyComment} position={data[0]?.pos} /> -
+ )}
}; diff --git a/frontend/pages/dashboard/[id].tsx b/frontend/pages/dashboard/[id].tsx index 60fcc7a00..d61ff4afb 100644 --- a/frontend/pages/dashboard/[id].tsx +++ b/frontend/pages/dashboard/[id].tsx @@ -241,9 +241,14 @@ export default function Dashboard() { sortValuesHandler(tempdata, sortMethod == "alhpabetical" ? "-alphabetical" : "alphabetical"); }; - const deleteRow = (id: string) => { + const deleteRow = ({ ids, secretName }: { ids: string[]; secretName: string; }) => { setButtonReady(true); - setData(data!.filter((row: SecretDataProps) => row.id !== id)); + toggleSidebar("None"); + createNotification({ + text: `${secretName} has been deleted. Remember to save changes.`, + type: 'error' + }); + setData(data!.filter((row: SecretDataProps) => !ids.includes(row.id))); }; /** @@ -395,8 +400,8 @@ export default function Dashboard() { alink.click(); }; - const deleteCertainRow = (id: string) => { - deleteRow(id); + const deleteCertainRow = ({ ids, secretName }: { ids: string[]; secretName: string; }) => { + deleteRow({ids, secretName}); }; /** @@ -438,6 +443,7 @@ export default function Dashboard() { savePush={savePush} sharedToHide={sharedToHide} setSharedToHide={setSharedToHide} + deleteRow={deleteCertainRow} />}
@@ -585,7 +591,6 @@ export default function Dashboard() { Date: Mon, 2 Jan 2023 20:41:20 -0800 Subject: [PATCH 24/46] Moved project id from dashboard to settings --- frontend/pages/dashboard/[id].tsx | 45 ------------------- frontend/pages/settings/project/[id].js | 59 ++++++++++++++++++++----- 2 files changed, 49 insertions(+), 55 deletions(-) diff --git a/frontend/pages/dashboard/[id].tsx b/frontend/pages/dashboard/[id].tsx index d61ff4afb..24c0a850c 100644 --- a/frontend/pages/dashboard/[id].tsx +++ b/frontend/pages/dashboard/[id].tsx @@ -86,7 +86,6 @@ export default function Dashboard() { const [isNew, setIsNew] = useState(false); const [searchKeys, setSearchKeys] = useState(''); const [errorDragAndDrop, setErrorDragAndDrop] = useState(false); - const [projectIdCopied, setProjectIdCopied] = useState(false); const [sortMethod, setSortMethod] = useState('alphabetical'); const [checkDocsPopUpVisible, setCheckDocsPopUpVisible] = useState(false); const [hasUserEverPushed, setHasUserEverPushed] = useState(false); @@ -404,23 +403,6 @@ export default function Dashboard() { deleteRow({ids, secretName}); }; - /** - * This function copies the project id to the clipboard - */ - function copyToClipboard() { - const copyText = document.getElementById('myInput') as HTMLInputElement; - - if (copyText) { - copyText.select(); - copyText.setSelectionRange(0, 99999); // For mobile devices - - navigator.clipboard.writeText(copyText.value); - - setProjectIdCopied(true); - setTimeout(() => setProjectIdCopied(false), 2000); - } - } - return data ? (
@@ -470,33 +452,6 @@ export default function Dashboard() { )}
-
-

{`${t( - "common:project-id" - )}:`}

- -
- - - {t("common:click-to-copy")} - -
-
{(data?.length !== 0 || buttonReady) && (
+ + {t("common:click-to-copy")} + +
From c7c5a947d25410142c359da53058063042933783 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 3 Jan 2023 15:53:06 +0700 Subject: [PATCH 25/46] Modify secret snapshots to point to secret versions --- backend/src/app.ts | 2 + backend/src/ee/controllers/v1/index.ts | 2 + .../v1/secretSnapshotController.ts | 27 ++++++ backend/src/ee/helpers/secret.ts | 44 ++++++---- backend/src/ee/middleware/index.ts | 7 ++ .../middleware/requireSecretSnapshotAuth.ts | 50 +++++++++++ backend/src/ee/models/secretSnapshot.ts | 86 ++----------------- backend/src/ee/models/secretVersion.ts | 41 +++++++++ backend/src/ee/routes/v1/index.ts | 2 + backend/src/ee/routes/v1/secret.ts | 2 +- backend/src/ee/routes/v1/secretSnapshot.ts | 26 ++++++ backend/src/helpers/secret.ts | 78 ++++++----------- backend/src/types/express/index.d.ts | 1 + backend/src/utils/errors.ts | 10 +++ 14 files changed, 229 insertions(+), 149 deletions(-) create mode 100644 backend/src/ee/controllers/v1/secretSnapshotController.ts create mode 100644 backend/src/ee/middleware/index.ts create mode 100644 backend/src/ee/middleware/requireSecretSnapshotAuth.ts create mode 100644 backend/src/ee/routes/v1/secretSnapshot.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 9fba18c67..4b02e0311 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,6 +14,7 @@ import { apiLimiter } from './helpers/rateLimiter'; import { workspace as eeWorkspaceRouter, secret as eeSecretRouter, + secretSnapshot as eeSecretSnapshotRouter, action as eeActionRouter } from './ee/routes/v1'; import { @@ -69,6 +70,7 @@ if (NODE_ENV === 'production') { // (EE) routes app.use('/api/v1/secret', eeSecretRouter); +app.use('/api/v1/secret-snapshot', eeSecretSnapshotRouter); app.use('/api/v1/workspace', eeWorkspaceRouter); app.use('/api/v1/action', eeActionRouter); diff --git a/backend/src/ee/controllers/v1/index.ts b/backend/src/ee/controllers/v1/index.ts index 2a082de70..dd88f1178 100644 --- a/backend/src/ee/controllers/v1/index.ts +++ b/backend/src/ee/controllers/v1/index.ts @@ -1,11 +1,13 @@ import * as stripeController from './stripeController'; import * as secretController from './secretController'; +import * as secretSnapshotController from './secretSnapshotController'; import * as workspaceController from './workspaceController'; import * as actionController from './actionController'; export { stripeController, secretController, + secretSnapshotController, workspaceController, actionController } \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/secretSnapshotController.ts b/backend/src/ee/controllers/v1/secretSnapshotController.ts new file mode 100644 index 000000000..40e1a74a6 --- /dev/null +++ b/backend/src/ee/controllers/v1/secretSnapshotController.ts @@ -0,0 +1,27 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { SecretSnapshot } from '../../models'; + +export const getSecretSnapshot = async (req: Request, res: Response) => { + let secretSnapshot; + try { + const { secretSnapshotId } = req.params; + + secretSnapshot = await SecretSnapshot + .findById(secretSnapshotId) + .populate('secretVersions'); + + if (!secretSnapshot) throw new Error('Failed to find secret snapshot'); + + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get secret snapshot' + }); + } + + return res.status(200).send({ + secretSnapshot + }); +} \ No newline at end of file diff --git a/backend/src/ee/helpers/secret.ts b/backend/src/ee/helpers/secret.ts index 2f726a9a5..529c9a980 100644 --- a/backend/src/ee/helpers/secret.ts +++ b/backend/src/ee/helpers/secret.ts @@ -23,34 +23,44 @@ import { }: { workspaceId: string; }) => { + let secretSnapshot; try { - const secrets = await Secret.find({ + const secretIds = (await Secret.find({ workspace: workspaceId - }); + }, '_id')).map((s) => s._id); + const 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) => s.versionId); + const latestSecretSnapshot = await SecretSnapshot.findOne({ workspace: workspaceId }).sort({ version: -1 }); - if (!latestSecretSnapshot) { - // case: no snapshots exist for workspace -> create first snapshot - await new SecretSnapshot({ - workspace: workspaceId, - version: 1, - secrets - }).save(); - - return; - } - - // case: snapshots exist for workspace secretSnapshot = await new SecretSnapshot({ workspace: workspaceId, - version: latestSecretSnapshot.version + 1, - secrets + version: latestSecretSnapshot ? latestSecretSnapshot.version + 1 : 1, + secretVersions: latestSecretVersions }).save(); - } catch (err) { Sentry.setUser(null); Sentry.captureException(err); diff --git a/backend/src/ee/middleware/index.ts b/backend/src/ee/middleware/index.ts new file mode 100644 index 000000000..ff9267965 --- /dev/null +++ b/backend/src/ee/middleware/index.ts @@ -0,0 +1,7 @@ +import requireLicenseAuth from './requireLicenseAuth'; +import requireSecretSnapshotAuth from './requireSecretSnapshotAuth'; + +export { + requireLicenseAuth, + requireSecretSnapshotAuth +} \ No newline at end of file diff --git a/backend/src/ee/middleware/requireSecretSnapshotAuth.ts b/backend/src/ee/middleware/requireSecretSnapshotAuth.ts new file mode 100644 index 000000000..71d7c5215 --- /dev/null +++ b/backend/src/ee/middleware/requireSecretSnapshotAuth.ts @@ -0,0 +1,50 @@ +import { Request, Response, NextFunction } from 'express'; +import { UnauthorizedRequestError, SecretSnapshotNotFoundError } from '../../utils/errors'; +import { SecretSnapshot } from '../models'; +import { + validateMembership +} from '../../helpers/membership'; + +/** + * Validate if user on request has proper membership for secret snapshot + * @param {Object} obj + * @param {String[]} obj.acceptedRoles - accepted workspace roles + * @param {String[]} obj.acceptedStatuses - accepted workspace statuses + * @param {String[]} obj.location - location of [workspaceId] on request (e.g. params, body) for parsing + */ +const requireSecretSnapshotAuth = ({ + acceptedRoles, + acceptedStatuses +}: { + acceptedRoles: string[]; + acceptedStatuses: string[]; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const { secretSnapshotId } = req.params; + + const secretSnapshot = await SecretSnapshot.findById(secretSnapshotId); + + if (!secretSnapshot) { + return next(SecretSnapshotNotFoundError({ + message: 'Failed to find secret snapshot' + })); + } + + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: secretSnapshot.workspace.toString(), + acceptedRoles, + acceptedStatuses + }); + + req.secretSnapshot = secretSnapshot as any; + + next(); + } catch (err) { + return next(UnauthorizedRequestError({ message: 'Unable to authenticate secret snapshot' })); + } + } +} + +export default requireSecretSnapshotAuth; \ No newline at end of file diff --git a/backend/src/ee/models/secretSnapshot.ts b/backend/src/ee/models/secretSnapshot.ts index 69633a92e..c646f353a 100644 --- a/backend/src/ee/models/secretSnapshot.ts +++ b/backend/src/ee/models/secretSnapshot.ts @@ -1,31 +1,9 @@ import { Schema, model, Types } from 'mongoose'; -import { - SECRET_SHARED, - SECRET_PERSONAL, - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD -} from '../../variables'; export interface ISecretSnapshot { workspace: Types.ObjectId; version: number; - secrets: { - version: number; - workspace: Types.ObjectId; - type: string; - user: Types.ObjectId; - environment: string; - secretKeyCiphertext: string; - secretKeyIV: string; - secretKeyTag: string; - secretKeyHash: string; - secretValueCiphertext: string; - secretValueIV: string; - secretValueTag: string; - secretValueHash: string; - }[] + secretVersions: Types.ObjectId[]; } const secretSnapshotSchema = new Schema( @@ -39,64 +17,10 @@ const secretSnapshotSchema = new Schema( type: Number, required: true }, - secrets: [{ - version: { - type: Number, - default: 1, - required: true - }, - workspace: { - type: Schema.Types.ObjectId, - ref: 'Workspace', - required: true - }, - type: { - type: String, - enum: [SECRET_SHARED, SECRET_PERSONAL], - required: true - }, - user: { - // user associated with the personal secret - type: Schema.Types.ObjectId, - ref: 'User' - }, - environment: { - type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], - required: true - }, - secretKeyCiphertext: { - type: String, - required: true - }, - secretKeyIV: { - type: String, // symmetric - required: true - }, - secretKeyTag: { - type: String, // symmetric - required: true - }, - secretKeyHash: { - type: String, - required: true - }, - secretValueCiphertext: { - type: String, - required: true - }, - secretValueIV: { - type: String, // symmetric - required: true - }, - secretValueTag: { - type: String, // symmetric - required: true - }, - secretValueHash: { - type: String, - required: true - } + secretVersions: [{ + type: Schema.Types.ObjectId, + ref: 'SecretVersion', + required: true }] }, { diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index a93a037f6..0197c3a25 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -1,9 +1,30 @@ import { Schema, model, Types } from 'mongoose'; +import { + SECRET_SHARED, + SECRET_PERSONAL, + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD +} from '../../variables'; + +/** + * TODO: + * 1. Modify SecretVersion to also contain XX + * - type + * - user + * - environment + * 2. Modify SecretSnapshot to point to arrays of SecretVersion + */ export interface ISecretVersion { _id?: Types.ObjectId; secret: Types.ObjectId; version: number; + workspace: Types.ObjectId; // new + type: string; // new + user: Types.ObjectId; // new + environment: string; // new isDeleted: boolean; secretKeyCiphertext: string; secretKeyIV: string; @@ -27,6 +48,26 @@ const secretVersionSchema = new Schema( default: 1, required: true }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + }, + type: { + type: String, + enum: [SECRET_SHARED, SECRET_PERSONAL], + required: true + }, + user: { + // user associated with the personal secret + type: Schema.Types.ObjectId, + ref: 'User' + }, + environment: { + type: String, + enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], + required: true + }, isDeleted: { type: Boolean, default: false, diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 02fc80939..612715111 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,9 +1,11 @@ import secret from './secret'; +import secretSnapshot from './secretSnapshot'; import workspace from './workspace'; import action from './action'; export { secret, + secretSnapshot, workspace, action } \ No newline at end of file diff --git a/backend/src/ee/routes/v1/secret.ts b/backend/src/ee/routes/v1/secret.ts index 7217c96e2..cb6897994 100644 --- a/backend/src/ee/routes/v1/secret.ts +++ b/backend/src/ee/routes/v1/secret.ts @@ -5,7 +5,7 @@ import { requireSecretAuth, validateRequest } from '../../../middleware'; -import { body, query, param } from 'express-validator'; +import { query, param } from 'express-validator'; import { secretController } from '../../controllers/v1'; import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../../../variables'; diff --git a/backend/src/ee/routes/v1/secretSnapshot.ts b/backend/src/ee/routes/v1/secretSnapshot.ts new file mode 100644 index 000000000..04f6c9d59 --- /dev/null +++ b/backend/src/ee/routes/v1/secretSnapshot.ts @@ -0,0 +1,26 @@ +import express from 'express'; +const router = express.Router(); +import { + requireSecretSnapshotAuth +} from '../../middleware'; +import { + requireAuth, + validateRequest +} from '../../../middleware'; +import { param } from 'express-validator'; +import { ADMIN, MEMBER, GRANTED } from '../../../variables'; +import { secretSnapshotController } from '../../controllers/v1'; + +router.get( + '/:secretSnapshotId', + requireAuth, + requireSecretSnapshotAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [GRANTED] + }), + param('secretSnapshotId').exists().trim(), + validateRequest, + secretSnapshotController.getSecretSnapshot +); + +export default router; \ No newline at end of file diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index f71f094e8..0d64da3b1 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import { Secret, ISecret, @@ -8,7 +9,6 @@ import { EELogService } from '../ee/services'; import { - SecretVersion, IAction } from '../ee/models'; import { @@ -104,11 +104,9 @@ const v1PushSecrets = async ({ await Secret.deleteMany({ _id: { $in: toDelete } }); - - await SecretVersion.updateMany({ - secret: { $in: toDelete } - }, { - isDeleted: true + + await EESecretService.markDeletedSecretVersions({ + secretIds: toDelete }); } @@ -191,6 +189,10 @@ const v1PushSecrets = async ({ return ({ secret: _id, version: version ? version + 1 : 1, + workspace: new Types.ObjectId(workspaceId), + type: newSecret.type, + user: new Types.ObjectId(userId), + environment, isDeleted: false, secretKeyCiphertext: newSecret.ciphertextKey, secretKeyIV: newSecret.ivKey, @@ -242,6 +244,11 @@ const v1PushSecrets = async ({ EESecretService.addSecretVersions({ secretVersions: newSecrets.map(({ _id, + version, + workspace, + type, + user, + environment, secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -252,7 +259,11 @@ const v1PushSecrets = async ({ secretValueHash }) => ({ secret: _id, - version: 1, + version, + workspace, + type, + user, + environment, isDeleted: false, secretKeyCiphertext, secretKeyIV, @@ -419,29 +430,14 @@ const v1PushSecrets = async ({ // (EE) add secret versions for updated secrets await EESecretService.addSecretVersions({ secretVersions: toUpdate.map((s) => { - const { - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretKeyHash, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretValueHash, - } = newSecretsObj[`${s.type}-${s.secretKeyHash}`]; - return ({ + ...newSecretsObj[`${s.type}-${s.secretKeyHash}`], secret: s._id, version: s.version ? s.version + 1 : 1, - isDeleted: false, - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretKeyHash, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretValueHash + workspace: new Types.ObjectId(workspaceId), + user: s.user, + environment: s.environment, + isDeleted: false }) }) }); @@ -474,31 +470,13 @@ const v1PushSecrets = async ({ // (EE) add secret versions for new secrets EESecretService.addSecretVersions({ - secretVersions: newSecrets.map(({ - _id, - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretKeyHash, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretValueHash - }) => ({ - secret: _id, - version: 1, - isDeleted: false, - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretKeyHash, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretValueHash + secretVersions: newSecrets.map((s) => ({ + ...s, + secret: s._id, + isDeleted: false })) }); - + const addAction = await EELogService.createActionSecret({ name: ACTION_ADD_SECRETS, userId, diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts index 68961a946..33b10e2bc 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -13,6 +13,7 @@ declare global { integrationAuth: any; bot: any; secret: any; + secretSnapshot: any; serviceToken: any; accessToken: any; query?: any; diff --git a/backend/src/utils/errors.ts b/backend/src/utils/errors.ts index ba5611465..3c6a385e5 100644 --- a/backend/src/utils/errors.ts +++ b/backend/src/utils/errors.ts @@ -123,6 +123,16 @@ export const SecretNotFoundError = (error?: Partial) => new stack: error?.stack }); +//* ----->[SECRET SNAPSHOT ERRORS]<----- +export const SecretSnapshotNotFoundError = (error?: Partial) => new RequestError({ + logLevel: error?.logLevel ?? LogLevel.ERROR, + statusCode: error?.statusCode ?? 404, + type: error?.type ?? 'secret_snapshot_not_found_error', + message: error?.message ?? 'The requested secret snapshot was not found', + context: error?.context, + stack: error?.stack +}); + //* ----->[ACTION ERRORS]<----- export const ActionNotFoundError = (error?: Partial) => new RequestError({ logLevel: error?.logLevel ?? LogLevel.ERROR, From fb394de4285557ff8f9dc69cc504aeaa4b960e00 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 3 Jan 2023 16:02:05 +0700 Subject: [PATCH 26/46] Remove unecessary imports --- backend/src/controllers/v2/workspaceController.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 95c03bbc4..637801fa2 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -1,29 +1,17 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; import { - Workspace, - Membership, - MembershipOrg, - Integration, - IntegrationAuth, Key, - IUser, - ServiceToken, } from '../../models'; -import { - createWorkspace as create, - deleteWorkspace as deleteWork -} from '../../helpers/workspace'; import { v2PushSecrets as push, pullSecrets as pull, reformatPullSecrets } from '../../helpers/secret'; import { pushKeys } from '../../helpers/key'; -import { addMemberships } from '../../helpers/membership'; import { postHogClient, EventService } from '../../services'; import { eventPushSecrets } from '../../events'; -import { ADMIN, COMPLETED, GRANTED, ENV_SET } from '../../variables'; +import { ENV_SET } from '../../variables'; interface V2PushSecret { type: string; // personal or shared From 5967a5cdbab104943044e547c46ad145e5e79e2f Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 4 Jan 2023 10:00:05 +0700 Subject: [PATCH 27/46] Add endpoint to return count of secret snapshots for a workspace --- .../ee/controllers/v1/workspaceController.ts | 27 ++++++++++++++++++- backend/src/ee/routes/v1/workspace.ts | 14 ++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 25a8c8d76..016baff67 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -1,4 +1,4 @@ -import { Request, Response } from 'express'; +import e, { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; import { SecretSnapshot, @@ -37,6 +37,31 @@ import { }); } +/** + * Return count of secret snapshots for workspace with id [workspaceId] + * @param req + * @param res + */ +export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Response) => { + let count; + try { + const { workspaceId } = req.params; + count = await SecretSnapshot.countDocuments({ + workspace: workspaceId + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to count number of secret snapshots' + }); + } + + return res.status(200).send({ + count + }); +} + /** * Return (audit) logs for workspace with id [workspaceId] * @param req diff --git a/backend/src/ee/routes/v1/workspace.ts b/backend/src/ee/routes/v1/workspace.ts index bca79dd91..f95ea7c93 100644 --- a/backend/src/ee/routes/v1/workspace.ts +++ b/backend/src/ee/routes/v1/workspace.ts @@ -25,6 +25,20 @@ router.get( workspaceController.getWorkspaceSecretSnapshots ); +router.get( + '/:workspaceId/secret-snapshots/count', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [GRANTED] + }), + param('workspaceId').exists().trim(), + validateRequest, + workspaceController.getWorkspaceSecretSnapshotsCount +); + router.get( '/:workspaceId/logs', requireAuth, From 15db7920580fc2841c129ff8aeeeacf32e544eff Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 4 Jan 2023 15:04:09 +0700 Subject: [PATCH 28/46] Patch requireAuth middleware in getting secret snapshot by id --- backend/src/ee/routes/v1/secretSnapshot.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/routes/v1/secretSnapshot.ts b/backend/src/ee/routes/v1/secretSnapshot.ts index 04f6c9d59..4b23f03e5 100644 --- a/backend/src/ee/routes/v1/secretSnapshot.ts +++ b/backend/src/ee/routes/v1/secretSnapshot.ts @@ -13,7 +13,9 @@ import { secretSnapshotController } from '../../controllers/v1'; router.get( '/:secretSnapshotId', - requireAuth, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), requireSecretSnapshotAuth({ acceptedRoles: [ADMIN, MEMBER], acceptedStatuses: [GRANTED] From 347b7201de0f82fa5e4cba411c43181c2492931a Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Wed, 4 Jan 2023 17:11:07 -0800 Subject: [PATCH 29/46] Finished secret snapshots --- .../ee/controllers/v1/workspaceController.ts | 1 + backend/src/ee/routes/v1/workspace.ts | 4 +- backend/src/helpers/secret.ts | 11 +- frontend/components/basic/Layout.tsx | 10 +- frontend/components/basic/buttons/Button.tsx | 2 +- .../context/Notifications/Notification.tsx | 4 +- .../Notifications/NotificationProvider.tsx | 2 +- frontend/components/dashboard/KeyPair.tsx | 15 +- .../api/secrets/GetProjectSercetShanpshots.ts | 39 +++ .../secrets/GetProjectSercetSnapshotsCount.ts | 31 +++ .../ee/api/secrets/GetSecretSnapshotData.ts | 31 +++ frontend/ee/components/PITRecoverySidebar.tsx | 158 +++++++++++ frontend/pages/dashboard/[id].tsx | 247 ++++++++++++++---- frontend/public/locales/en/activity.json | 3 +- 14 files changed, 485 insertions(+), 73 deletions(-) create mode 100644 frontend/ee/api/secrets/GetProjectSercetShanpshots.ts create mode 100644 frontend/ee/api/secrets/GetProjectSercetSnapshotsCount.ts create mode 100644 frontend/ee/api/secrets/GetSecretSnapshotData.ts create mode 100644 frontend/ee/components/PITRecoverySidebar.tsx diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 016baff67..88c31b8e1 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -21,6 +21,7 @@ import { secretSnapshots = await SecretSnapshot.find({ workspace: workspaceId }) + .sort({ createdAt: -1 }) .skip(offset) .limit(limit); diff --git a/backend/src/ee/routes/v1/workspace.ts b/backend/src/ee/routes/v1/workspace.ts index 48c054970..4b2e839eb 100644 --- a/backend/src/ee/routes/v1/workspace.ts +++ b/backend/src/ee/routes/v1/workspace.ts @@ -39,7 +39,9 @@ router.get( router.get( '/:workspaceId/logs', - requireAuth, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER] }), diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index 0d64da3b1..920e8dc1d 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -470,11 +470,12 @@ const v1PushSecrets = async ({ // (EE) add secret versions for new secrets EESecretService.addSecretVersions({ - secretVersions: newSecrets.map((s) => ({ - ...s, - secret: s._id, - isDeleted: false - })) + secretVersions: newSecrets.map((secretDocument) => { + return { + ...secretDocument.toObject(), + secret: secretDocument._id, + isDeleted: false + }}) }); const addAction = await EELogService.createActionSecret({ diff --git a/frontend/components/basic/Layout.tsx b/frontend/components/basic/Layout.tsx index 8bdabc97b..8ac9ec92b 100644 --- a/frontend/components/basic/Layout.tsx +++ b/frontend/components/basic/Layout.tsx @@ -121,7 +121,7 @@ export default function Layout({ children }: LayoutProps) { } }); } - router.push("/dashboard/" + newWorkspaceId + "?Development"); + router.push("/dashboard/" + newWorkspaceId); setIsOpen(false); setNewWorkspaceName(""); } else { @@ -141,8 +141,7 @@ export default function Layout({ children }: LayoutProps) { { href: "/dashboard/" + - workspaceMapping[workspaceSelected as any] + - "?Development", + workspaceMapping[workspaceSelected as any], title: t("nav:menu.secrets"), emoji: , }, @@ -199,7 +198,7 @@ export default function Layout({ children }: LayoutProps) { .map((workspace: { _id: string }) => workspace._id) .includes(intendedWorkspaceId) ) { - router.push("/dashboard/" + userWorkspaces[0]._id + "?Development"); + router.push("/dashboard/" + userWorkspaces[0]._id); } else { setWorkspaceList( userWorkspaces.map((workspace: any) => workspace.name) @@ -242,8 +241,7 @@ export default function Layout({ children }: LayoutProps) { ) { router.push( "/dashboard/" + - workspaceMapping[workspaceSelected as any] + - "?Development" + workspaceMapping[workspaceSelected as any] ); localStorage.setItem( "projectData.id", diff --git a/frontend/components/basic/buttons/Button.tsx b/frontend/components/basic/buttons/Button.tsx index 9197ccf13..939d9b17d 100644 --- a/frontend/components/basic/buttons/Button.tsx +++ b/frontend/components/basic/buttons/Button.tsx @@ -115,7 +115,7 @@ export default function Button(props: ButtonProps): JSX.Element { )} diff --git a/frontend/components/context/Notifications/Notification.tsx b/frontend/components/context/Notifications/Notification.tsx index ad556a6f5..6635c795e 100644 --- a/frontend/components/context/Notifications/Notification.tsx +++ b/frontend/components/context/Notifications/Notification.tsx @@ -36,7 +36,7 @@ const Notification = ({ return (
{notification.type === 'error' && ( @@ -56,7 +56,7 @@ const Notification = ({ onClick={() => clearNotification(notification.text)} > diff --git a/frontend/components/context/Notifications/NotificationProvider.tsx b/frontend/components/context/Notifications/NotificationProvider.tsx index 05f9eee19..aa694a1d0 100644 --- a/frontend/components/context/Notifications/NotificationProvider.tsx +++ b/frontend/components/context/Notifications/NotificationProvider.tsx @@ -38,7 +38,7 @@ const NotificationProvider = ({ children }: NotificationProviderProps) => { const createNotification = ({ text, type = 'success', - timeoutMs = 5000 + timeoutMs = 4000 }: Notification) => { const doesNotifExist = notifications.some((notif) => notif.text === text); diff --git a/frontend/components/dashboard/KeyPair.tsx b/frontend/components/dashboard/KeyPair.tsx index 52495829a..ba6cc6ee7 100644 --- a/frontend/components/dashboard/KeyPair.tsx +++ b/frontend/components/dashboard/KeyPair.tsx @@ -21,6 +21,7 @@ interface KeyPairProps { isDuplicate: boolean; toggleSidebar: (id: string) => void; sidebarSecretId: string; + isSnapshot: boolean; } /** @@ -33,6 +34,7 @@ interface KeyPairProps { * @param {boolean} obj.isDuplicate - list of all the duplicates secret names on the dashboard * @param {function} obj.toggleSidebar - open/close/switch sidebar * @param {string} obj.sidebarSecretId - the id of a secret for the side bar is displayed + * @param {boolean} obj.isSnapshot - whether this keyPair is in a snapshot. If so, it won't have some features like sidebar * @returns */ const KeyPair = ({ @@ -42,10 +44,11 @@ const KeyPair = ({ isBlurred, isDuplicate, toggleSidebar, - sidebarSecretId + sidebarSecretId, + isSnapshot }: KeyPairProps) => { return ( -
+
{keyPair.type == "personal" &&
@@ -65,7 +68,7 @@ const KeyPair = ({
-
+
-
toggleSidebar(keyPair.id)} className="cursor-pointer w-[2.35rem] h-[2.35rem] bg-mineshaft-700 hover:bg-chicago-700 rounded-md flex flex-row justify-center items-center duration-200"> + {!isSnapshot &&
toggleSidebar(keyPair.id)} className="cursor-pointer w-[2.35rem] h-[2.35rem] bg-mineshaft-700 hover:bg-chicago-700 rounded-md flex flex-row justify-center items-center duration-200"> -
+
}
); }; -export default React.memo(KeyPair); \ No newline at end of file +export default KeyPair; \ No newline at end of file diff --git a/frontend/ee/api/secrets/GetProjectSercetShanpshots.ts b/frontend/ee/api/secrets/GetProjectSercetShanpshots.ts new file mode 100644 index 000000000..21c2ac801 --- /dev/null +++ b/frontend/ee/api/secrets/GetProjectSercetShanpshots.ts @@ -0,0 +1,39 @@ +import SecurityClient from '~/utilities/SecurityClient'; + + +interface workspaceProps { + workspaceId: string; + offset: number; + limit: number; +} + +/** + * This function fetches the secret snapshots for a certain project + * @param {object} obj + * @param {string} obj.workspaceId - project id for which we are trying to get project secret snapshots + * @param {object} obj.offset - teh starting point of snapshots that we want to pull + * @param {object} obj.limit - how many snapshots will we output + * @returns + */ +const getProjectSecretShanpshots = async ({ workspaceId, offset, limit }: workspaceProps) => { + return SecurityClient.fetchCall( + '/api/v1/workspace/' + workspaceId + '/secret-snapshots?' + + new URLSearchParams({ + offset: String(offset), + limit: String(limit) + }), { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + ).then(async (res) => { + if (res && res.status == 200) { + return (await res.json()).secretSnapshots; + } else { + console.log('Failed to get project secret snapshots'); + } + }); +}; + +export default getProjectSecretShanpshots; diff --git a/frontend/ee/api/secrets/GetProjectSercetSnapshotsCount.ts b/frontend/ee/api/secrets/GetProjectSercetSnapshotsCount.ts new file mode 100644 index 000000000..19389026b --- /dev/null +++ b/frontend/ee/api/secrets/GetProjectSercetSnapshotsCount.ts @@ -0,0 +1,31 @@ +import SecurityClient from '~/utilities/SecurityClient'; + + +interface workspaceProps { + workspaceId: string; +} + +/** + * This function fetches the count of secret snapshots for a certain project + * @param {object} obj + * @param {string} obj.workspaceId - project id for which we are trying to get project secret snapshots + * @returns + */ +const getProjectSercetSnapshotsCount = async ({ workspaceId }: workspaceProps) => { + return SecurityClient.fetchCall( + '/api/v1/workspace/' + workspaceId + '/secret-snapshots/count', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + ).then(async (res) => { + if (res && res.status == 200) { + return (await res.json()).count; + } else { + console.log('Failed to get the count of project secret snapshots'); + } + }); +}; + +export default getProjectSercetSnapshotsCount; diff --git a/frontend/ee/api/secrets/GetSecretSnapshotData.ts b/frontend/ee/api/secrets/GetSecretSnapshotData.ts new file mode 100644 index 000000000..181fa85ce --- /dev/null +++ b/frontend/ee/api/secrets/GetSecretSnapshotData.ts @@ -0,0 +1,31 @@ +import SecurityClient from '~/utilities/SecurityClient'; + + +interface SnapshotProps { + secretSnapshotId: string; +} + +/** + * This function fetches the secrets for a certain secret snapshot + * @param {object} obj + * @param {string} obj.secretSnapshotId - snapshot id for which we are trying to get secrets + * @returns + */ +const getSecretSnapshotData = async ({ secretSnapshotId }: SnapshotProps) => { + return SecurityClient.fetchCall( + '/api/v1/secret-snapshot/' + secretSnapshotId, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + ).then(async (res) => { + if (res && res.status == 200) { + return (await res.json()).secretSnapshot; + } else { + console.log('Failed to get the secrets of a certain snapshot'); + } + }); +}; + +export default getSecretSnapshotData; diff --git a/frontend/ee/components/PITRecoverySidebar.tsx b/frontend/ee/components/PITRecoverySidebar.tsx new file mode 100644 index 000000000..e763d695b --- /dev/null +++ b/frontend/ee/components/PITRecoverySidebar.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import { useTranslation } from "next-i18next"; +import { faX } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import getProjectSecretShanpshots from "ee/api/secrets/GetProjectSercetShanpshots"; +import getSecretSnapshotData from "ee/api/secrets/GetSecretSnapshotData"; +import timeSince from "ee/utilities/timeSince"; + +import Button from "~/components/basic/buttons/Button"; +import { decryptAssymmetric, decryptSymmetric } from "~/components/utilities/cryptography/crypto"; +import getLatestFileKey from "~/pages/api/workspace/getLatestFileKey"; + + +interface SideBarProps { + toggleSidebar: (value: boolean) => void; + setSnapshotData: (value: any) => void; + chosenSnapshot: string; +} + +interface SnaphotProps { + _id: string; + createdAt: string; + secretVersions: string[]; +} + +interface EncrypetedSecretVersionListProps { + _id: string; + createdAt: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + environment: string; + type: "personal" | "shared"; +} + +/** + * @param {object} obj + * @param {function} obj.toggleSidebar - function that opens or closes the sidebar + * @param {function} obj.setSnapshotData - state manager for snapshot data + * @param {string} obj.chosenSnaphshot - the snapshot id which is currently selected + * + * + * @returns the sidebar with the options for point-in-time recovery (commits) + */ +const PITRecoverySidebar = ({ + toggleSidebar, + setSnapshotData, + chosenSnapshot +}: SideBarProps) => { + const { t } = useTranslation(); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [secretSnapshotsMetadata, setSecretSnapshotsMetadata] = useState([]); + const [currentOffset, setCurrentOffset] = useState(0); + const currentLimit = 15; + + const loadMoreSnapshots = () => { + setCurrentOffset(currentOffset + currentLimit); + } + + useEffect(() => { + const getLogData = async () => { + setIsLoading(true); + const results = await getProjectSecretShanpshots({ workspaceId: String(router.query.id), limit: currentLimit, offset: currentOffset }) + setSecretSnapshotsMetadata(secretSnapshotsMetadata.concat(results)); + setIsLoading(false); + } + getLogData(); + }, [currentOffset]); + + const exploreSnapshot = async ({ snapshotId }: { snapshotId: string; }) => { + const secretSnapshotData = await getSecretSnapshotData({ secretSnapshotId: snapshotId }); + + const latestKey = await getLatestFileKey({ workspaceId: String(router.query.id) }) + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); + + let decryptedLatestKey: string; + if (latestKey) { + // assymmetrically decrypt symmetric key with local private key + decryptedLatestKey = decryptAssymmetric({ + ciphertext: latestKey.latestKey.encryptedKey, + nonce: latestKey.latestKey.nonce, + publicKey: latestKey.latestKey.sender.publicKey, + privateKey: String(PRIVATE_KEY) + }); + } + + const decryptedSecretVersions = secretSnapshotData.secretVersions.map((encryptedSecretVersion: EncrypetedSecretVersionListProps, pos: number) => { + return { + id: encryptedSecretVersion._id, + pos: pos, + type: encryptedSecretVersion.type, + environment: encryptedSecretVersion.environment, + key: decryptSymmetric({ + ciphertext: encryptedSecretVersion.secretKeyCiphertext, + iv: encryptedSecretVersion.secretKeyIV, + tag: encryptedSecretVersion.secretKeyTag, + key: decryptedLatestKey + }), + value: decryptSymmetric({ + ciphertext: encryptedSecretVersion.secretValueCiphertext, + iv: encryptedSecretVersion.secretValueIV, + tag: encryptedSecretVersion.secretValueTag, + key: decryptedLatestKey + }) + } + }) + + setSnapshotData({ id: secretSnapshotData._id, createdAt: secretSnapshotData.createdAt, secretVersions: decryptedSecretVersions }) + } + + return
+ {isLoading ? ( +
+ infisical loading indicator +
+ ) : ( +
+
+

{t("Point-in-time Recovery")}

+
toggleSidebar(false)}> + +
+
+
+ {secretSnapshotsMetadata?.map((snapshot: SnaphotProps, id: number) =>
+
+
{timeSince(new Date(snapshot.createdAt))}
+
{" - " + snapshot.secretVersions.length + " Secrets"}
+
+
exploreSnapshot({ snapshotId: snapshot._id })} + className={`${chosenSnapshot == snapshot._id || (id == 0 && chosenSnapshot === "") ? "text-bunker-800 pointer-events-none" : "text-bunker-200 hover:text-primary duration-200 cursor-pointer"} text-sm`}> + {id == 0 ? "Current Version" : chosenSnapshot == snapshot._id ? "Currently Viewing" : "Explore"} +
+
)} +
+
+
+
+
+
+ )} +
+}; + +export default PITRecoverySidebar; diff --git a/frontend/pages/dashboard/[id].tsx b/frontend/pages/dashboard/[id].tsx index 24c0a850c..d6e104f57 100644 --- a/frontend/pages/dashboard/[id].tsx +++ b/frontend/pages/dashboard/[id].tsx @@ -6,8 +6,9 @@ import { useTranslation } from "next-i18next"; import { faArrowDownAZ, faArrowDownZA, + faArrowLeft, faCheck, - faCopy, + faClockRotateLeft, faDownload, faEye, faEyeSlash, @@ -16,6 +17,8 @@ import { faPlus, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import getProjectSercetSnapshotsCount from 'ee/api/secrets/GetProjectSercetSnapshotsCount'; +import PITRecoverySidebar from 'ee/components/PITRecoverySidebar'; import Button from '~/components/basic/buttons/Button'; import ListBox from '~/components/basic/Listbox'; @@ -30,12 +33,13 @@ import pushKeys from '~/components/utilities/secrets/pushKeys'; import { getTranslatedServerSideProps } from '~/components/utilities/withTranslateProps'; import guidGenerator from '~/utilities/randomId'; -import { envMapping } from '../../public/data/frequentConstants'; +import { envMapping, reverseEnvMapping } from '../../public/data/frequentConstants'; import getUser from '../api/user/getUser'; import checkUserAction from '../api/userActions/checkUserAction'; import registerUserAction from '../api/userActions/registerUserAction'; import getWorkspaces from '../api/workspace/getWorkspaces'; +const queryString = require("query-string"); interface SecretDataProps { type: 'personal' | 'shared'; @@ -46,6 +50,19 @@ interface SecretDataProps { comment: string; } +interface SnapshotProps { + id: string; + createdAt: string; + secretVersions: { + id: string; + pos: number; + type: "personal" | "shared"; + environment: string; + key: string; + value: string; + }[]; +} + /** * this function finds the teh duplicates in an array * @param arr - array of anything (e.g., with secret keys and types (personal/shared)) @@ -76,21 +93,20 @@ export default function Dashboard() { const [workspaceId, setWorkspaceId] = useState(''); const [blurred, setBlurred] = useState(true); const [isKeyAvailable, setIsKeyAvailable] = useState(true); - const [env, setEnv] = useState( - router.asPath.split('?').length == 1 - ? 'Development' - : Object.keys(envMapping).includes(router.asPath.split('?')[1]) - ? router.asPath.split('?')[1] - : 'Development' - ); + const [env, setEnv] = useState('Development'); + const [snapshotEnv, setSnapshotEnv] = useState('Development'); const [isNew, setIsNew] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [searchKeys, setSearchKeys] = useState(''); const [errorDragAndDrop, setErrorDragAndDrop] = useState(false); const [sortMethod, setSortMethod] = useState('alphabetical'); const [checkDocsPopUpVisible, setCheckDocsPopUpVisible] = useState(false); const [hasUserEverPushed, setHasUserEverPushed] = useState(false); const [sidebarSecretId, toggleSidebar] = useState("None"); + const [PITSidebarOpen, togglePITSidebar] = useState(false); const [sharedToHide, setSharedToHide] = useState([]); + const [snapshotData, setSnapshotData] = useState(); + const [numSnapshots, setNumSnapshots] = useState(); const { t } = useTranslation(); const { createNotification } = useNotificationContext(); @@ -141,17 +157,39 @@ export default function Dashboard() { useEffect(() => { (async () => { try { + console.log(1, 'reloaded') + const tempNumSnapshots = await getProjectSercetSnapshotsCount({ workspaceId: String(router.query.id) }) + setNumSnapshots(tempNumSnapshots); const userWorkspaces = await getWorkspaces(); const listWorkspaces = userWorkspaces.map((workspace) => workspace._id); if ( - !listWorkspaces.includes(router.asPath.split('/')[2].split('?')[0]) + !listWorkspaces.includes(router.asPath.split('/')[2]) ) { router.push('/dashboard/' + listWorkspaces[0]); } - if (env != router.asPath.split('?')[1]) { - router.push(router.asPath.split('?')[0] + '?' + env); - } + const user = await getUser(); + setIsNew( + (Date.parse(String(new Date())) - Date.parse(user.createdAt)) / 60000 < 3 + ? true + : false + ); + + const userAction = await checkUserAction({ + action: 'first_time_secrets_pushed' + }); + setHasUserEverPushed(userAction ? true : false); + } catch (error) { + console.log('Error', error); + setData(undefined); + } + })(); + }, []); + + useEffect(() => { + (async () => { + try { + setIsLoading(true); setBlurred(true); setWorkspaceId(String(router.query.id)); @@ -173,18 +211,7 @@ export default function Dashboard() { dataToSort?.map((item) => item.key).indexOf(item) ).includes(row.key) && row.type == 'shared'))?.map((item) => item.id) ) - - const user = await getUser(); - setIsNew( - (Date.parse(String(new Date())) - Date.parse(user.createdAt)) / 60000 < 3 - ? true - : false - ); - - const userAction = await checkUserAction({ - action: 'first_time_secrets_pushed' - }); - setHasUserEverPushed(userAction ? true : false); + setIsLoading(false); } catch (error) { console.log('Error', error); setData(undefined); @@ -321,12 +348,21 @@ export default function Dashboard() { /** * Save the changes of environment variables and push them to the database */ - const savePush = async () => { - // Format the new object with environment variables - const obj = Object.assign( - {}, - ...data!.map((row: SecretDataProps) => ({ [row.type.charAt(0) + row.key]: [row.value, row.comment] })) - ); + const savePush = async (dataToPush?: any[], envToPush?: string) => { + let obj; + // dataToPush is mostly used for rollbacks, otherwise we always take the current state data + if ((dataToPush ?? [])?.length > 0) { + obj = Object.assign( + {}, + ...dataToPush!.map((row: SecretDataProps) => ({ [row.type.charAt(0) + row.key]: [row.value, row.comment ?? ''] })) + ); + } else { + // Format the new object with environment variables + obj = Object.assign( + {}, + ...data!.map((row: SecretDataProps) => ({ [row.type.charAt(0) + row.key]: [row.value, row.comment ?? ''] })) + ); + } // Checking if any of the secret keys start with a number - if so, don't do anything const nameErrors = !Object.keys(obj) @@ -350,13 +386,17 @@ export default function Dashboard() { // Once "Save changed is clicked", disable that button setButtonReady(false); - pushKeys({ obj, workspaceId: String(router.query.id), env }); + console.log(envToPush ? envToPush : env, env, envToPush) + pushKeys({ obj, workspaceId: String(router.query.id), env: envToPush ? envToPush : env }); // If this user has never saved environment variables before, show them a prompt to read docs if (!hasUserEverPushed) { setCheckDocsPopUpVisible(true); await registerUserAction({ action: 'first_time_secrets_pushed' }); } + + // increasing the number of project commits + setNumSnapshots(numSnapshots ?? 0 + 1); }; const addData = (newData: SecretDataProps[]) => { @@ -427,6 +467,11 @@ export default function Dashboard() { setSharedToHide={setSharedToHide} deleteRow={deleteCertainRow} />} + {PITSidebarOpen && }
{checkDocsPopUpVisible && ( @@ -441,9 +486,22 @@ export default function Dashboard() { /> )}
+ {snapshotData && +
+
}
-

{t("dashboard:title")}

- {data?.length == 0 && ( +
+

{snapshotData ? "Secret Snapshot" : t("dashboard:title")}

+ {snapshotData && {new Date(snapshotData.createdAt).toLocaleString()}} +
+ {!snapshotData && data?.length == 0 && (
- {(data?.length !== 0 || buttonReady) && ( -
+
+
+ {(data?.length !== 0 || buttonReady) && !snapshotData && ( +
)} + {snapshotData &&
+
}
- {data?.length !== 0 && ( + {(!snapshotData || data?.length !== 0) && ( <> - + : }
-
+ {!snapshotData &&
-
+
} + {!snapshotData &&
+
}
-
+ {!snapshotData &&
+
} )}
- {data?.length !== 0 ? ( + {isLoading ? ( +
+ infisical loading indicator +
+ ) : ( + data?.length !== 0 ? (
- {data?.filter(row => !(sharedToHide.includes(row.id) && row.type == 'shared')).map((keyPair) => ( + {!snapshotData && data?.filter(row => row.key.toUpperCase().includes(searchKeys.toUpperCase())) + .filter(row => !(sharedToHide.includes(row.id) && row.type == 'shared')).map((keyPair) => ( item.key + item.type))?.includes(keyPair.key + keyPair.type)} toggleSidebar={toggleSidebar} sidebarSecretId={sidebarSecretId} + isSnapshot={false} + /> + ))} + {snapshotData && snapshotData.secretVersions?.sort((a, b) => a.key.localeCompare(b.key)) + .filter(row => reverseEnvMapping[row.environment] == snapshotEnv) + .filter(row => row.key.toUpperCase().includes(searchKeys.toUpperCase())) + .filter(row => !(snapshotData.secretVersions?.filter(row => (snapshotData.secretVersions + ?.map((item) => item.key) + .filter( + (item, index) => + index !== + snapshotData.secretVersions?.map((item) => item.key).indexOf(item) + ).includes(row.key) && row.type == 'shared'))?.map((item) => item.id).includes(row.id) && row.type == 'shared')).map((keyPair) => ( + item.key + item.type))?.includes(keyPair.key + keyPair.type)} + toggleSidebar={toggleSidebar} + sidebarSecretId={sidebarSecretId} + isSnapshot={true} /> ))}
-
+ {!snapshotData &&
-
+
}
) : (
- {isKeyAvailable && ( + {isKeyAvailable && !snapshotData && ( ))}
- )} + ))}
diff --git a/frontend/public/locales/en/activity.json b/frontend/public/locales/en/activity.json index b84d570ae..5dff03d1e 100644 --- a/frontend/public/locales/en/activity.json +++ b/frontend/public/locales/en/activity.json @@ -2,6 +2,7 @@ "event": { "readSecrets": "Secrets Viewed", "updateSecrets": "Secrets Updated", - "addSecrets": "Secrets Added" + "addSecrets": "Secrets Added", + "deleteSecrets": "Secrets Deleted" } } From 6c88c4dc3677ebac9ee084acaeb943f493e35931 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Wed, 4 Jan 2023 17:34:30 -0800 Subject: [PATCH 30/46] Updated the image for signup invites --- frontend/pages/signupinvite.js | 6 +- .../public/images/dragon-signupinvite.svg | 129 ++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 frontend/public/images/dragon-signupinvite.svg diff --git a/frontend/pages/signupinvite.js b/frontend/pages/signupinvite.js index 169c5c014..d32d6038d 100644 --- a/frontend/pages/signupinvite.js +++ b/frontend/pages/signupinvite.js @@ -141,16 +141,16 @@ export default function SignupInvite() { // Step 4 of the sign up process (download the emergency kit pdf) const stepConfirmEmail = (
-

+

Confirm your email

verify email -
+
@@ -176,7 +176,7 @@ const AddServiceTokenDialog = ({ "6 months", "12 months", ]} - width="full" + isFull={true} text={`${t("common:expired-in")}: `} />
@@ -211,7 +211,7 @@ const AddServiceTokenDialog = ({
-
+
- {t("common.click-to-copy")} + {t("common:click-to-copy")}
diff --git a/frontend/components/basic/table/ServiceTokenTable.js b/frontend/components/basic/table/ServiceTokenTable.tsx similarity index 62% rename from frontend/components/basic/table/ServiceTokenTable.js rename to frontend/components/basic/table/ServiceTokenTable.tsx index 6e5cc65ea..a79acd759 100644 --- a/frontend/components/basic/table/ServiceTokenTable.js +++ b/frontend/components/basic/table/ServiceTokenTable.tsx @@ -1,19 +1,37 @@ -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/router'; import { faX } from '@fortawesome/free-solid-svg-icons'; +import { useNotificationContext } from '~/components/context/Notifications/NotificationProvider'; + +import deleteServiceToken from "../../../pages/api/serviceToken/deleteServiceToken"; import { reverseEnvMapping } from '../../../public/data/frequentConstants'; import guidGenerator from '../../utilities/randomId'; import Button from '../buttons/Button'; +interface TokenProps { + _id: string; + name: string; + environment: string; + expiresAt: string; +} + +interface ServiceTokensProps { + data: TokenProps[]; + workspaceName: string; + setServiceTokens: (value: TokenProps[]) => void; +} + /** - * This is the component that we utilize for the user table - in future, can reuse it for some other purposes too. + * This is the component that we utilize for the service token table * #TODO: add the possibility of choosing and doing operations on multiple users. - * @param {*} props + * @param {object} obj + * @param {any[]} obj.data - current state of the service token table + * @param {string} obj.workspaceName - name of the current project + * @param {function} obj.setServiceTokens - updating the state of the service token table * @returns */ -const ServiceTokenTable = ({ data, workspaceName }) => { - const router = useRouter(); +const ServiceTokenTable = ({ data, workspaceName, setServiceTokens }: ServiceTokensProps) => { + console.log(data) + const { createNotification } = useNotificationContext(); return (
@@ -30,7 +48,7 @@ const ServiceTokenTable = ({ data, workspaceName }) => { {data?.length > 0 ? ( - data.map((row, index) => { + data.map((row) => { return ( {