From f51e9ba8ff1b8d57c4cfe6dea829bedcd2b35265 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Mon, 25 Sep 2023 11:51:17 -0700 Subject: [PATCH 01/30] add back role migration --- backend/src/utils/setup/backfillData.ts | 15 +++++++++++++++ backend/src/utils/setup/index.ts | 4 +++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/src/utils/setup/backfillData.ts b/backend/src/utils/setup/backfillData.ts index b420761a7..cc481b011 100644 --- a/backend/src/utils/setup/backfillData.ts +++ b/backend/src/utils/setup/backfillData.ts @@ -819,3 +819,18 @@ export const backfillPermission = async () => { console.info("Could not acquire lock for script [backfillPermission], skipping"); } }; + +export const migrateRoleFromOwnerToAdmin = async () => { + await MembershipOrg.updateMany( + { + role: OWNER + }, + { + $set: { + role: ADMIN + } + } + ); + + console.info("Backfill: Finished converting owner role to member"); +} \ No newline at end of file diff --git a/backend/src/utils/setup/index.ts b/backend/src/utils/setup/index.ts index c4e06ff2c..d85d63aca 100644 --- a/backend/src/utils/setup/index.ts +++ b/backend/src/utils/setup/index.ts @@ -18,7 +18,8 @@ import { backfillServiceToken, backfillServiceTokenMultiScope, backfillTrustedIps, - backfillUserAuthMethods + backfillUserAuthMethods, + migrateRoleFromOwnerToAdmin } from "./backfillData"; import { reencryptBotOrgKeys, @@ -85,6 +86,7 @@ export const setup = async () => { await backfillTrustedIps(); await backfillUserAuthMethods(); // await backfillPermission(); + await migrateRoleFromOwnerToAdmin() // re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY // to base64 256-bit ROOT_ENCRYPTION_KEY From e11abb619a9d5b7bf407f350a7fbb08fdef2d21f Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Mon, 25 Sep 2023 15:10:59 -0700 Subject: [PATCH 02/30] use v1.0.2 of internal keyring in cli --- cli/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/go.mod b/cli/go.mod index 084bc8e45..c67ccb1e5 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -75,4 +75,4 @@ require ( github.com/zalando/go-keyring v0.2.3 ) -replace github.com/zalando/go-keyring => github.com/Infisical/go-keyring v1.0.1 +replace github.com/zalando/go-keyring => github.com/Infisical/go-keyring v1.0.2 From 5128466233e20bcd756775e94909d5d2ae5783d9 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Mon, 25 Sep 2023 15:28:13 -0700 Subject: [PATCH 03/30] update go.mod after Infisical/go-keyring update --- cli/go.sum | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/go.sum b/cli/go.sum index 114125554..fd46e8656 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -39,8 +39,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Infisical/go-keyring v1.0.1 h1:E8XpqoT0H1G9C1kgxU+NeReXOeobmH7LbBHNpcOI380= -github.com/Infisical/go-keyring v1.0.1/go.mod h1:LWOnn/sw9FxDW/0VY+jHFAfOFEe03xmwBVSfJnBowto= +github.com/Infisical/go-keyring v1.0.2 h1:dWOkI/pB/7RocfSJgGXbXxLDcVYsdslgjEPmVhb+nl8= +github.com/Infisical/go-keyring v1.0.2/go.mod h1:LWOnn/sw9FxDW/0VY+jHFAfOFEe03xmwBVSfJnBowto= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= From 4c1324baa97fff73096b7e6dbba48d3039e14a32 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Tue, 26 Sep 2023 12:25:23 +0530 Subject: [PATCH 04/30] feat: added support for getting imported secrets in v3 getSecret api --- .../src/controllers/v3/secretsController.ts | 10 ++-- backend/src/helpers/secrets.ts | 11 +++- .../services/SecretService/index.ts | 3 +- backend/src/services/SecretImportService.ts | 52 +++++++++++++++++++ backend/src/validation/secrets.ts | 12 ++++- 5 files changed, 79 insertions(+), 9 deletions(-) diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index a528436af..a553315bd 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -146,7 +146,7 @@ export const getSecretsRaw = async (req: Request, res: Response) => { */ export const getSecretByNameRaw = async (req: Request, res: Response) => { const { - query: { secretPath, environment, workspaceId, type }, + query: { secretPath, environment, workspaceId, type, include_imports }, params: { secretName } } = await validateRequest(reqValidator.GetSecretByNameRawV3, req); @@ -172,7 +172,8 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => { environment, type, secretPath, - authData: req.authData + authData: req.authData, + include_imports }); const key = await BotService.getWorkspaceKeyWithBot({ @@ -483,7 +484,7 @@ export const getSecrets = async (req: Request, res: Response) => { */ export const getSecretByName = async (req: Request, res: Response) => { const { - query: { secretPath, environment, workspaceId, type }, + query: { secretPath, environment, workspaceId, type, include_imports }, params: { secretName } } = await validateRequest(reqValidator.GetSecretByNameV3, req); @@ -509,7 +510,8 @@ export const getSecretByName = async (req: Request, res: Response) => { environment, type, secretPath, - authData: req.authData + authData: req.authData, + include_imports }); return res.status(200).send({ diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 3799dc1e7..5b4c8b465 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -48,6 +48,7 @@ import { getAuthDataPayloadIdObj, getAuthDataPayloadUserObj } from "../utils/aut import { getFolderByPath, getFolderIdFromServiceToken } from "../services/FolderService"; import picomatch from "picomatch"; import path from "path"; +import { getAnImportedSecret } from "../services/SecretImportService"; export const isValidScope = ( authPayload: IServiceTokenData, @@ -622,13 +623,14 @@ export const getSecretHelper = async ({ environment, type, authData, - secretPath = "/" + secretPath = "/", + include_imports = true }: GetSecretParams) => { const secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, workspaceId: new Types.ObjectId(workspaceId) }); - let secret: ISecret | null = null; + let secret: ISecret | null | undefined = null; // if using service token filter towards the folderId by secretpath const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); @@ -655,6 +657,11 @@ export const getSecretHelper = async ({ }).lean(); } + if (!secret && include_imports) { + // if still no secret found search in imported secret and retreive + secret = await getAnImportedSecret(secretName, workspaceId.toString(), environment, folderId); + } + if (!secret) throw SecretNotFoundError(); // (EE) create (audit) log diff --git a/backend/src/interfaces/services/SecretService/index.ts b/backend/src/interfaces/services/SecretService/index.ts index 9ea82ce94..f1f89a83b 100644 --- a/backend/src/interfaces/services/SecretService/index.ts +++ b/backend/src/interfaces/services/SecretService/index.ts @@ -19,7 +19,7 @@ export interface CreateSecretParams { secretPath: string; metadata?: { source?: string; - } + }; } export interface GetSecretsParams { @@ -37,6 +37,7 @@ export interface GetSecretParams { environment: string; type?: "shared" | "personal"; authData: AuthData; + include_imports?: boolean; } export interface UpdateSecretParams { diff --git a/backend/src/services/SecretImportService.ts b/backend/src/services/SecretImportService.ts index b432976fc..f23cd2018 100644 --- a/backend/src/services/SecretImportService.ts +++ b/backend/src/services/SecretImportService.ts @@ -1,9 +1,61 @@ import { Types } from "mongoose"; +import { generateSecretBlindIndexHelper } from "../helpers"; import { Folder, ISecret, Secret, SecretImport } from "../models"; import { getFolderByPath } from "./FolderService"; type TSecretImportFid = { environment: string; folderId: string; secretPath: string }; +export const getAnImportedSecret = async ( + secretName: string, + workspaceId: string, + environment: string, + folderId = "root" +) => { + const secretBlindIndex = await generateSecretBlindIndexHelper({ + secretName, + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secImports = await SecretImport.findOne({ + workspace: workspaceId, + environment, + folderId + }); + if (!secImports) return; + if (secImports.imports.length === 0) return; + const folders = await Folder.find({ + workspace: workspaceId, + environment: { $in: secImports.imports.map((el) => el.environment) } + }); + + const importedSecByFid: TSecretImportFid[] = []; + secImports.imports.forEach((el) => { + const folder = folders.find((fl) => fl.environment === el.environment); + if (folder) { + const secPathFolder = getFolderByPath(folder.nodes, el.secretPath); + if (secPathFolder) + importedSecByFid.push({ + environment: el.environment, + folderId: secPathFolder.id, + secretPath: el.secretPath + }); + } else { + if (el.secretPath === "/") { + // this happens when importing with a fresh env without any folders + importedSecByFid.push({ environment: el.environment, folderId: "root", secretPath: "/" }); + } + } + }); + if (importedSecByFid.length === 0) return; + + const secret = await Secret.findOne({ + workspace: workspaceId, + secretBlindIndex + }).or(importedSecByFid.map(({ environment, folderId }) => ({ environment, folder: folderId }))); + + return secret; +}; + export const getAllImportedSecrets = async ( workspaceId: string, environment: string, diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index 28e7f469d..18579aa27 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -244,7 +244,11 @@ export const GetSecretByNameRawV3 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().default("/"), - type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).optional() + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).optional(), + include_imports: z + .enum(["true", "false"]) + .default("true") + .transform((value) => value === "true") }) }); @@ -305,7 +309,11 @@ export const GetSecretByNameV3 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().default("/"), - type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).optional() + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).optional(), + include_imports: z + .enum(["true", "false"]) + .default("true") + .transform((value) => value === "true") }), params: z.object({ secretName: z.string().trim() From 8265d18934f39691e57ea2002b3b1eb47280341d Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 26 Sep 2023 22:00:00 -0700 Subject: [PATCH 05/30] nit: add lean option --- backend/src/services/SecretImportService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/SecretImportService.ts b/backend/src/services/SecretImportService.ts index f23cd2018..d07c841f5 100644 --- a/backend/src/services/SecretImportService.ts +++ b/backend/src/services/SecretImportService.ts @@ -51,7 +51,7 @@ export const getAnImportedSecret = async ( const secret = await Secret.findOne({ workspace: workspaceId, secretBlindIndex - }).or(importedSecByFid.map(({ environment, folderId }) => ({ environment, folder: folderId }))); + }).or(importedSecByFid.map(({ environment, folderId }) => ({ environment, folder: folderId }))).lean() return secret; }; From 9cda85f03ed44c4f29a677782f59df6f9eb05467 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 4 Sep 2023 15:05:54 +0530 Subject: [PATCH 06/30] checkpoint --- backend/src/models/secretApproval.ts | 44 ++++++ backend/src/models/secretApprovalRequest.ts | 128 ++++++++---------- frontend/public/locales/en/translations.json | 5 + frontend/src/layouts/AppLayout/AppLayout.tsx | 12 ++ .../src/pages/project/[id]/approval/index.tsx | 27 ++++ .../SecretApprovalListPage.tsx | 73 ++++++++++ .../SecretApprovalListPage/index.tsx | 1 + 7 files changed, 218 insertions(+), 72 deletions(-) create mode 100644 backend/src/models/secretApproval.ts create mode 100644 frontend/src/pages/project/[id]/approval/index.tsx create mode 100644 frontend/src/views/SecretApproval/SecretApprovalListPage/SecretApprovalListPage.tsx create mode 100644 frontend/src/views/SecretApproval/SecretApprovalListPage/index.tsx diff --git a/backend/src/models/secretApproval.ts b/backend/src/models/secretApproval.ts new file mode 100644 index 000000000..f4e2fc92e --- /dev/null +++ b/backend/src/models/secretApproval.ts @@ -0,0 +1,44 @@ +import { Schema, Types, model } from "mongoose"; + +export interface ISecretApproval { + _id: Types.ObjectId; + workspace: Types.ObjectId; + environment: string; + secretPath?: string; + approvers: Types.ObjectId[]; + approvals: number; +} + +const secretApprovalSchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + approvers: [ + { + // user associated with the personal secret + type: Schema.Types.ObjectId, + ref: "Membership" + } + ], + environment: { + type: String, + required: true + }, + secretPath: { + type: String, + required: false + }, + approvals: { + type: Number, + default: 1 + } + }, + { + timestamps: true + } +); + +export const SecretApproval = model("SecretApproval", secretApprovalSchema); diff --git a/backend/src/models/secretApprovalRequest.ts b/backend/src/models/secretApprovalRequest.ts index 910ca9135..4133f06a7 100644 --- a/backend/src/models/secretApprovalRequest.ts +++ b/backend/src/models/secretApprovalRequest.ts @@ -1,81 +1,65 @@ -import mongoose, { Schema, model } from "mongoose"; -import { ISecret, Secret } from "./secret"; +import { Schema, Types, model } from "mongoose"; +import { ISecretVersion, SecretVersion } from "../ee/models/secretVersion"; -interface ISecretApprovalRequest { - secret: mongoose.Types.ObjectId; - requestedChanges: ISecret; - requestedBy: mongoose.Types.ObjectId; - approvers: IApprover[]; - status: ApprovalStatus; - timestamp: Date; - requestType: RequestType; - requestId: string; +enum ApprovalStatus { + PENDING = "pending", + APPROVED = "approved", + REJECTED = "rejected" } -interface IApprover { - userId: mongoose.Types.ObjectId; - status: ApprovalStatus; +enum CommitType { + DELETE = "delete", + UPDATE = "update", + CREATE = "create" } -export enum ApprovalStatus { - PENDING = "pending", - APPROVED = "approved", - REJECTED = "rejected" +export interface ISecretApprovalRequest { + _id: Types.ObjectId; + committer: Types.ObjectId; + approvers: { + member: Types.ObjectId; + status: ApprovalStatus; + }[]; + approvals: number; + hasMerged: boolean; + status: ApprovalStatus; + commits: { + secretVersion: Types.ObjectId; + newVersion: ISecretVersion; + op: CommitType; + }[]; } -export enum RequestType { - UPDATE = "update", - DELETE = "delete", - CREATE = "create" -} - -const approverSchema = new mongoose.Schema({ - user: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, - }, - status: { - type: String, - enum: [ApprovalStatus], - default: ApprovalStatus.PENDING, - }, -}); - -const secretApprovalRequestSchema = new Schema( - { - secret: { - type: mongoose.Schema.Types.ObjectId, - ref: "Secret", - }, - requestedChanges: Secret, - requestedBy: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - }, - approvers: [approverSchema], - status: { - type: String, - enum: ApprovalStatus, - default: ApprovalStatus.PENDING, - }, - timestamp: { - type: Date, - default: Date.now, - }, - requestType: { - type: String, - enum: RequestType, - required: true, - }, - requestId: { - type: String, - required: false, - }, - }, - { - timestamps: true, - } +const secretApprovalSchema = new Schema( + { + approvers: [ + { + member: { + // user associated with the personal secret + type: Schema.Types.ObjectId, + ref: "Membership" + }, + status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING } + } + ], + approvals: { + type: Number, + required: true + }, + hasMerged: { type: Boolean, default: false }, + status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING }, + committer: { type: Schema.Types.ObjectId, ref: "Membership" }, + commits: [ + { + secretVersion: { type: Types.ObjectId, ref: "SecretVersion" }, + newVersion: SecretVersion, + op: { type: String, enum: [CommitType], required: true } + } + ] + }, + { + timestamps: true + } ); -export const SecretApprovalRequest = model("SecretApprovalRequest", secretApprovalRequestSchema); \ No newline at end of file +export const SecretApproval = model("SecretApproval", secretApprovalSchema); diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index f989f2f3a..9900176e1 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -107,6 +107,11 @@ } } }, + "approval": { + "title": "Admin Panel", + "og-title": "Manage your secret change management", + "og-description": "Infisical a simple end-to-end encrypted platform that enables teams to sync and manage their .env files." + }, "integrations": { "title": "Project Integrations", "description": "Manage your integrations of Infisical with third-party services.", diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 05f266f49..d8c522110 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -475,6 +475,18 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + Secret Change Management + + + { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("approval.title") })} + + + + + +
+ +
+ + ); +}; + +export default SecretApproval; + +SecretApproval.requireAuth = true; diff --git a/frontend/src/views/SecretApproval/SecretApprovalListPage/SecretApprovalListPage.tsx b/frontend/src/views/SecretApproval/SecretApprovalListPage/SecretApprovalListPage.tsx new file mode 100644 index 000000000..3264382fb --- /dev/null +++ b/frontend/src/views/SecretApproval/SecretApprovalListPage/SecretApprovalListPage.tsx @@ -0,0 +1,73 @@ +import { faCheck, faCodeBranch, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, Table, TableContainer, TBody, Td, Th, THead, Tr } from "@app/components/v2"; + +export const SecretApprovalListPage = () => { + return ( +
+
+

Admin Panels

+
+
+
+
+ + 27 Open +
+
+ + 27 Closed +
+
+
+
+
+ 2 secrets added and 1 deleted +
+ + Opened 2 hours ago by akhilmhdh - Review required + +
+
+
+
+
+ Request for secret change +
+
+ + + + + + + + + + + + + + + + + + +
SecretValueCommentTags
TWILIO_SECRET_TOKENvalueSome values-
+
+
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/views/SecretApproval/SecretApprovalListPage/index.tsx b/frontend/src/views/SecretApproval/SecretApprovalListPage/index.tsx new file mode 100644 index 000000000..71d0f7cf3 --- /dev/null +++ b/frontend/src/views/SecretApproval/SecretApprovalListPage/index.tsx @@ -0,0 +1 @@ +export { SecretApprovalListPage } from "./SecretApprovalListPage"; From 6e992858aa754ed51f143b5998b0bf83422f30da Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Wed, 27 Sep 2023 15:12:32 +0530 Subject: [PATCH 07/30] fix: add renamed fields to other models --- .../controllers/v2/environmentController.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts index 72365d7da..d89e9b9b6 100644 --- a/backend/src/controllers/v2/environmentController.ts +++ b/backend/src/controllers/v2/environmentController.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { + Folder, Integration, Membership, Secret, @@ -21,9 +22,12 @@ import { getUserProjectPermissions } from "../../ee/services/ProjectRoleService"; import { ForbiddenError } from "@casl/ability"; +import { SecretImport } from "../../models"; +import { ServiceAccountWorkspacePermission } from "../../models"; +import { Webhook } from "../../models"; /** - * Create new workspace environment named [environmentName] + * Create new workspace environment named [environmentName] * with slug [environmentSlug] under workspace with id * @param req * @param res @@ -376,6 +380,27 @@ export const renameWorkspaceEnvironment = async (req: Request, res: Response) => { workspace: workspaceId, environment: oldEnvironmentSlug }, { environment: environmentSlug } ); + + await Folder.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + + await SecretImport.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + + await ServiceAccountWorkspacePermission.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + + await Webhook.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + await Membership.updateMany( { workspace: workspaceId, From 77ec17ccd409d58ba94bc1a86a24418b0853e657 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Wed, 27 Sep 2023 17:01:02 +0530 Subject: [PATCH 08/30] fix: update many query --- backend/src/controllers/v2/environmentController.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts index d89e9b9b6..b111d4566 100644 --- a/backend/src/controllers/v2/environmentController.ts +++ b/backend/src/controllers/v2/environmentController.ts @@ -373,8 +373,12 @@ export const renameWorkspaceEnvironment = async (req: Request, res: Response) => { environment: environmentSlug } ); await ServiceTokenData.updateMany( - { workspace: workspaceId, environment: oldEnvironmentSlug }, - { environment: environmentSlug } + { + workspace: workspaceId, + "scopes.environment": oldEnvironmentSlug + }, + { $set: { "scopes.$[element].environment": environmentSlug } }, + { arrayFilters: [{ "element.environment": oldEnvironmentSlug }] } ); await Integration.updateMany( { workspace: workspaceId, environment: oldEnvironmentSlug }, From edeb6bbc6683ba22610e387b74b88c3e26b75b52 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 27 Sep 2023 23:10:28 +0530 Subject: [PATCH 09/30] feat(secret-approval): implemented backend api for secret policies --- backend/src/controllers/v1/index.ts | 4 +- .../v1/secretApprovalController.ts | 109 ++++++++++++++++++ backend/src/ee/services/ProjectRoleService.ts | 12 +- backend/src/index.ts | 2 + backend/src/routes/v1/index.ts | 4 +- backend/src/routes/v1/secretApproval.ts | 39 +++++++ backend/src/validation/index.ts | 1 + backend/src/validation/secretApproval.ts | 34 ++++++ 8 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 backend/src/controllers/v1/secretApprovalController.ts create mode 100644 backend/src/routes/v1/secretApproval.ts create mode 100644 backend/src/validation/secretApproval.ts diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index a9bde9971..9dcbb12c2 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -16,6 +16,7 @@ import * as workspaceController from "./workspaceController"; import * as secretScanningController from "./secretScanningController"; import * as webhookController from "./webhookController"; import * as secretImpsController from "./secretImpsController"; +import * as secretApprovalController from "./secretApprovalController"; export { authController, @@ -35,5 +36,6 @@ export { workspaceController, secretScanningController, webhookController, - secretImpsController + secretImpsController, + secretApprovalController }; diff --git a/backend/src/controllers/v1/secretApprovalController.ts b/backend/src/controllers/v1/secretApprovalController.ts new file mode 100644 index 000000000..8441a93b0 --- /dev/null +++ b/backend/src/controllers/v1/secretApprovalController.ts @@ -0,0 +1,109 @@ +import { ForbiddenError } from "@casl/ability"; +import { Request, Response } from "express"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { validateRequest } from "../../helpers/validation"; +import { SecretApproval } from "../../models/secretApproval"; +import { BadRequestError } from "../../utils/errors"; +import * as reqValidator from "../../validation/secretApproval"; + +const ERR_SECRET_APPROVAL_NOT_FOUND = BadRequestError({ message: "secret approval not found" }); + +export const createSecretApprovalRule = async (req: Request, res: Response) => { + const { + body: { approvals, secretPath, approvers, environment, workspaceId } + } = await validateRequest(reqValidator.CreateSecretApprovalRule, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SecretApproval + ); + + const secretApproval = new SecretApproval({ + workspace: workspaceId, + secretPath, + environment, + approvals, + approvers + }); + await secretApproval.save(); + + return res.send({ + approval: secretApproval + }); +}; + +export const updateSecretApprovalRule = async (req: Request, res: Response) => { + const { + body: { approvals, approvers, secretPath }, + params: { id } + } = await validateRequest(reqValidator.UpdateSecretApprovalRule, req); + + const secretApproval = await SecretApproval.findById(id); + if (!secretApproval) throw ERR_SECRET_APPROVAL_NOT_FOUND; + + const { permission } = await getUserProjectPermissions( + req.user._id, + secretApproval.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.SecretApproval + ); + + const updatedDoc = await SecretApproval.findByIdAndUpdate(id, { + approvals, + approvers, + $set: secretPath === "-" ? undefined : { secretPath } + }); + + return res.send({ + approval: updatedDoc + }); +}; + +export const deleteSecretApprovalRule = async (req: Request, res: Response) => { + const { + params: { id } + } = await validateRequest(reqValidator.DeleteSecretApprovalRule, req); + + const secretApproval = await SecretApproval.findById(id); + if (!secretApproval) throw ERR_SECRET_APPROVAL_NOT_FOUND; + + const { permission } = await getUserProjectPermissions( + req.user._id, + secretApproval.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SecretApproval + ); + + const deletedDoc = await SecretApproval.findByIdAndDelete(id); + + return res.send({ + approval: deletedDoc + }); +}; + +export const getSecretApprovalRules = async (req: Request, res: Response) => { + const { + query: { workspaceId } + } = await validateRequest(reqValidator.GetSecretApprovalRuleList, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SecretApproval + ); + + const doc = await SecretApproval.find({ workspace: workspaceId }); + + return res.send({ + approvals: doc + }); +}; diff --git a/backend/src/ee/services/ProjectRoleService.ts b/backend/src/ee/services/ProjectRoleService.ts index 51f4a26f1..867edb7e5 100644 --- a/backend/src/ee/services/ProjectRoleService.ts +++ b/backend/src/ee/services/ProjectRoleService.ts @@ -49,7 +49,8 @@ export enum ProjectPermissionSub { IpAllowList = "ip-allowlist", Workspace = "workspace", Secrets = "secrets", - SecretRollback = "secret-rollback" + SecretRollback = "secret-rollback", + SecretApproval = "secret-approval" } type SubjectFields = { @@ -72,6 +73,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] + | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] @@ -85,6 +87,11 @@ const buildAdminPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); @@ -154,6 +161,8 @@ const buildMemberPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); @@ -203,6 +212,7 @@ const buildViewerPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); diff --git a/backend/src/index.ts b/backend/src/index.ts index aa4440a55..c3aed4b7b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -38,6 +38,7 @@ import { membership as v1MembershipRouter, organization as v1OrganizationRouter, password as v1PasswordRouter, + secretApproval as v1SecretApproval, secretImps as v1SecretImpsRouter, secret as v1SecretRouter, secretsFolder as v1SecretsFolder, @@ -176,6 +177,7 @@ const main = async () => { app.use("/api/v1/webhooks", v1WebhooksRouter); app.use("/api/v1/secret-imports", v1SecretImpsRouter); app.use("/api/v1/roles", v1RoleRouter); + app.use("/api/v1/secret-approvals", v1SecretApproval); // v2 routes (improvements) app.use("/api/v2/signup", v2SignupRouter); diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 46e74d58c..7c0f94972 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -17,6 +17,7 @@ import integrationAuth from "./integrationAuth"; import secretsFolder from "./secretsFolder"; import webhooks from "./webhook"; import secretImps from "./secretImps"; +import secretApproval from "./secretApproval"; export { signup, @@ -37,5 +38,6 @@ export { integrationAuth, secretsFolder, webhooks, - secretImps + secretImps, + secretApproval }; diff --git a/backend/src/routes/v1/secretApproval.ts b/backend/src/routes/v1/secretApproval.ts new file mode 100644 index 000000000..213bb3cc3 --- /dev/null +++ b/backend/src/routes/v1/secretApproval.ts @@ -0,0 +1,39 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../middleware"; +import { secretApprovalController } from "../../controllers/v1"; +import { AuthMode } from "../../variables"; + +router.get( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalController.getSecretApprovalRules +); + +router.post( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalController.createSecretApprovalRule +); + +router.patch( + "/:id", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalController.updateSecretApprovalRule +); + +router.delete( + "/:id", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalController.deleteSecretApprovalRule +); + +export default router; diff --git a/backend/src/validation/index.ts b/backend/src/validation/index.ts index 409b563c6..85899a535 100644 --- a/backend/src/validation/index.ts +++ b/backend/src/validation/index.ts @@ -1,3 +1,4 @@ +export * from "./secretApproval"; export * from "./user"; export * from "./workspace"; export * from "./bot"; diff --git a/backend/src/validation/secretApproval.ts b/backend/src/validation/secretApproval.ts new file mode 100644 index 000000000..a25da81b2 --- /dev/null +++ b/backend/src/validation/secretApproval.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export const GetSecretApprovalRuleList = z.object({ + query: z.object({ + workspaceId: z.string() + }) +}); + +export const CreateSecretApprovalRule = z.object({ + body: z.object({ + workspaceId: z.string(), + environment: z.string(), + secretPath: z.string().optional(), + approvers: z.string().array().optional(), + approvals: z.number().min(1).default(1) + }) +}); + +export const UpdateSecretApprovalRule = z.object({ + params: z.object({ + id: z.string() + }), + body: z.object({ + approvers: z.string().array().optional(), + approvals: z.number().min(1).optional(), + secretPath: z.string().optional() + }) +}); + +export const DeleteSecretApprovalRule = z.object({ + params: z.object({ + id: z.string() + }) +}); From c67432a56fe72606fcfd9f7c47acee1ded7d24c6 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 27 Sep 2023 23:10:45 +0530 Subject: [PATCH 10/30] feat(secret-approval): implemented frontend ui for secret policies --- frontend/src/components/v2/Modal/Modal.tsx | 4 +- frontend/src/hooks/api/index.tsx | 1 + .../src/hooks/api/secretApproval/index.tsx | 6 + .../src/hooks/api/secretApproval/mutation.tsx | 58 +++++ .../src/hooks/api/secretApproval/queries.tsx | 36 +++ .../src/hooks/api/secretApproval/types.ts | 31 +++ frontend/src/hooks/api/types.ts | 1 + frontend/src/layouts/AppLayout/AppLayout.tsx | 26 +- .../src/pages/project/[id]/approval/index.tsx | 4 +- .../SecretApprovalListPage.tsx | 73 ------ .../SecretApprovalListPage/index.tsx | 1 - .../SecretApprovalPage/SecretApprovalPage.tsx | 31 +++ .../SecretApprovalPolicyList.tsx | 128 ++++++++++ .../components/SecretApprovalPolicyRow.tsx | 120 +++++++++ .../components/SecretPolicyForm.tsx | 239 ++++++++++++++++++ .../SecretApprovalPolicyList/index.tsx | 1 + .../src/views/SecretApprovalPage/index.tsx | 1 + 17 files changed, 671 insertions(+), 90 deletions(-) create mode 100644 frontend/src/hooks/api/secretApproval/index.tsx create mode 100644 frontend/src/hooks/api/secretApproval/mutation.tsx create mode 100644 frontend/src/hooks/api/secretApproval/queries.tsx create mode 100644 frontend/src/hooks/api/secretApproval/types.ts delete mode 100644 frontend/src/views/SecretApproval/SecretApprovalListPage/SecretApprovalListPage.tsx delete mode 100644 frontend/src/views/SecretApproval/SecretApprovalListPage/index.tsx create mode 100644 frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx create mode 100644 frontend/src/views/SecretApprovalPage/index.tsx diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index 200ceefb2..97e1f3d3c 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -22,14 +22,14 @@ export const ModalContent = forwardRef( ) => ( diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index f3a7ab4ca..05de5a8d5 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -7,6 +7,7 @@ export * from "./integrations"; export * from "./keys"; export * from "./organization"; export * from "./roles"; +export * from "./secretApproval"; export * from "./secretFolders"; export * from "./secretImports"; export * from "./secrets"; diff --git a/frontend/src/hooks/api/secretApproval/index.tsx b/frontend/src/hooks/api/secretApproval/index.tsx new file mode 100644 index 000000000..1d4353d9e --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/index.tsx @@ -0,0 +1,6 @@ +export { + useCreateSecretApprovalPolicy, + useDeleteSecretApprovalPolicy, + useUpdateSecretApprovalPolicy +} from "./mutation"; +export { useGetSecretApprovalPolicies } from "./queries"; diff --git a/frontend/src/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx new file mode 100644 index 000000000..f171636a0 --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/mutation.tsx @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { secretApprovalKeys } from "./queries"; +import { TCreateSecretPolicyDTO, TDeleteSecretPolicyDTO, TUpdateSecretPolicyDTO } from "./types"; + +export const useCreateSecretApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateSecretPolicyDTO>({ + mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath }) => { + const { data } = await apiRequest.post("/api/v1/secret-approvals", { + environment, + workspaceId, + approvals, + approvers, + secretPath + }); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretApprovalKeys.getApprovalPolicies(workspaceId)); + } + }); +}; + +export const useUpdateSecretApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSecretPolicyDTO>({ + mutationFn: async ({ id, approvers, approvals, secretPath }) => { + const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { + approvals, + approvers, + secretPath + }); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretApprovalKeys.getApprovalPolicies(workspaceId)); + } + }); +}; + +export const useDeleteSecretApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSecretPolicyDTO>({ + mutationFn: async ({ id }) => { + const { data } = await apiRequest.delete(`/api/v1/secret-approvals/${id}`); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretApprovalKeys.getApprovalPolicies(workspaceId)); + } + }); +}; diff --git a/frontend/src/hooks/api/secretApproval/queries.tsx b/frontend/src/hooks/api/secretApproval/queries.tsx new file mode 100644 index 000000000..6c176a27a --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/queries.tsx @@ -0,0 +1,36 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSecretApprovalPolicy } from "./types"; + +export const secretApprovalKeys = { + getApprovalPolicies: (workspaceId: string) => + [{ workspaceId }, "secret-approval-policies"] as const +}; + +const fetchApprovalPolicies = async (workspaceId: string) => { + const { data } = await apiRequest.get<{ approvals: TSecretApprovalPolicy[] }>( + "/api/v1/secret-approvals", + { params: { workspaceId } } + ); + return data.approvals; +}; + +export const useGetSecretApprovalPolicies = ({ + workspaceId, + options = {} +}: { workspaceId: string } & { + options?: UseQueryOptions< + TSecretApprovalPolicy[], + unknown, + TSecretApprovalPolicy[], + ReturnType + >; +}) => + useQuery({ + queryKey: secretApprovalKeys.getApprovalPolicies(workspaceId), + queryFn: () => fetchApprovalPolicies(workspaceId), + ...options, + enabled: Boolean(workspaceId) && (options?.enabled ?? true) + }); diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts new file mode 100644 index 000000000..b77fc1a91 --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -0,0 +1,31 @@ +export type TSecretApprovalPolicy = { + _id: string; + workspace: string; + environment: string; + secretPath?: string; + approvers: string[]; + approvals: number; +}; + +export type TCreateSecretPolicyDTO = { + workspaceId: string; + environment: string; + secretPath?: string; + approvers?: string[]; + approvals?: number; +}; + +export type TUpdateSecretPolicyDTO = { + id: string; + approvers?: string[]; + secretPath?: string; + approvals?: number; + // for invalidating list + workspaceId: string; +}; + +export type TDeleteSecretPolicyDTO = { + id: string; + // for invalidating list + workspaceId: string; +}; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index 098078090..c91fb551d 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -4,6 +4,7 @@ export type { IntegrationAuth } from "./integrationAuth/types"; export type { TCloudIntegration, TIntegration } from "./integrations/types"; export type { UserWsKeyPair } from "./keys/types"; export type { Organization } from "./organization/types"; +export type { TSecretApprovalPolicy } from "./secretApproval/types"; export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types"; export type { SubscriptionPlan } from "./subscriptions/types"; export type { WsTag } from "./tags/types"; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index d8c522110..8dd5f12ab 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -475,18 +475,20 @@ export const AppLayout = ({ children }: LayoutProps) => {
- - - - Secret Change Management - - - + {process.env.NEXT_PUBLIC_SECRET_APPROVAL === "true" && ( + + + + Admin Panel + + + + )} { const { t } = useTranslation(); @@ -16,7 +16,7 @@ const SecretApproval = () => {
- +
); diff --git a/frontend/src/views/SecretApproval/SecretApprovalListPage/SecretApprovalListPage.tsx b/frontend/src/views/SecretApproval/SecretApprovalListPage/SecretApprovalListPage.tsx deleted file mode 100644 index 3264382fb..000000000 --- a/frontend/src/views/SecretApproval/SecretApprovalListPage/SecretApprovalListPage.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { faCheck, faCodeBranch, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { Button, Table, TableContainer, TBody, Td, Th, THead, Tr } from "@app/components/v2"; - -export const SecretApprovalListPage = () => { - return ( -
-
-

Admin Panels

-
-
-
-
- - 27 Open -
-
- - 27 Closed -
-
-
-
-
- 2 secrets added and 1 deleted -
- - Opened 2 hours ago by akhilmhdh - Review required - -
-
-
-
-
- Request for secret change -
-
- - - - - - - - - - - - - - - - - - -
SecretValueCommentTags
TWILIO_SECRET_TOKENvalueSome values-
-
-
-
- - -
-
-
- ); -}; diff --git a/frontend/src/views/SecretApproval/SecretApprovalListPage/index.tsx b/frontend/src/views/SecretApproval/SecretApprovalListPage/index.tsx deleted file mode 100644 index 71d0f7cf3..000000000 --- a/frontend/src/views/SecretApproval/SecretApprovalListPage/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { SecretApprovalListPage } from "./SecretApprovalListPage"; diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx new file mode 100644 index 000000000..35243e75f --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -0,0 +1,31 @@ +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; + +import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; + +enum TabSection { + ApprovalRequests = "approval-requests", + Rules = "approval-rules" +} + +export const SecretApprovalPage = () => { + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + + return ( +
+
+

Admin Panels

+
+ + + Secret PRs + Policies + + + + + +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx new file mode 100644 index 000000000..dcc27e49f --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx @@ -0,0 +1,128 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { usePopUp } from "@app/hooks"; +import { + useDeleteSecretApprovalPolicy, + useGetSecretApprovalPolicies, + useGetWorkspaceUsers +} from "@app/hooks/api"; +import { TSecretApprovalPolicy } from "@app/hooks/api/types"; + +import { SecretApprovalPolicyRow } from "./components/SecretApprovalPolicyRow"; +import { SecretPolicyForm } from "./components/SecretPolicyForm"; + +type Props = { + workspaceId: string; +}; + +export const SecretApprovalPolicyList = ({ workspaceId }: Props) => { + const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([ + "secretPolicyForm", + "deletePolicy" + ] as const); + const { createNotification } = useNotificationContext(); + + const { data: members } = useGetWorkspaceUsers(workspaceId); + const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({ + workspaceId + }); + + const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); + + const handleDeletePolicy = async () => { + const { _id: id } = popUp.deletePolicy.data as TSecretApprovalPolicy; + try { + await deleteSecretApprovalPolicy({ + workspaceId, + id + }); + createNotification({ + type: "success", + text: "Successfully deleted policy" + }); + handlePopUpClose("deletePolicy"); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete policy" + }); + } + }; + + return ( +
+
+
+ Approval Policies +
+ Implement policies to prevent unauthorized secret changes. +
+
+
+ +
+
+ + + + + + + + + + + + {isPoliciesLoading && ( + + )} + {policies?.map((policy) => ( + handlePopUpOpen("secretPolicyForm", policy)} + onDelete={() => handlePopUpOpen("deletePolicy", policy)} + /> + ))} + +
EnvironmentSecret PathEligible ApproversApproval Required +
+
+ handlePopUpToggle("secretPolicyForm", isOpen)} + members={members} + editValues={popUp.secretPolicyForm.data as TSecretApprovalPolicy} + /> + handlePopUpToggle("deletePolicy", isOpen)} + onDeleteApproved={handleDeletePolicy} + /> +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx new file mode 100644 index 000000000..c39ba2b69 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + IconButton, + Input, + Td, + Tooltip, + Tr +} from "@app/components/v2"; +import { useUpdateSecretApprovalPolicy } from "@app/hooks/api"; +import { TSecretApprovalPolicy } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + policy: TSecretApprovalPolicy; + members?: TWorkspaceUser[]; + workspaceId: string; + onEdit: () => void; + onDelete: () => void; +}; + +export const SecretApprovalPolicyRow = ({ + policy, + members = [], + workspaceId, + onEdit, + onDelete +}: Props) => { + const [selectedApprovers, setSelectedApprovers] = useState([]); + const { mutate: updateSecretApprovalPolicy, isLoading } = useUpdateSecretApprovalPolicy(); + + return ( + + {policy.environment} + {policy.secretPath || "*"} + + { + if (!isOpen) { + updateSecretApprovalPolicy( + { + workspaceId, + id: policy._id, + approvers: selectedApprovers + }, + { + onSettled: () => { + setSelectedApprovers([]); + } + } + ); + } else { + setSelectedApprovers(policy.approvers); + } + }} + > + + + + + Select members that must approve changes + {members?.map(({ _id, user }) => { + const isChecked = selectedApprovers.includes(_id); + return ( + { + evt.preventDefault(); + setSelectedApprovers((state) => + isChecked ? state.filter((el) => el !== _id) : [...state, _id] + ); + }} + key={`create-policy-members-${_id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + {policy.approvals} + +
+ + + + + + + + + + +
+ + + ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx new file mode 100644 index 000000000..78d1e3fbc --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx @@ -0,0 +1,239 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; +import { TSecretApprovalPolicy } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + workspaceId: string; + editValues?: TSecretApprovalPolicy; +}; + +const formSchema = z.object({ + environment: z.string(), + secretPath: z.string().optional(), + approvals: z.number().min(1), + approvers: z.string().array().optional() +}); + +type TFormSchema = z.infer; + +export const SecretPolicyForm = ({ + isOpen, + onToggle, + members = [], + workspaceId, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues + }); + const { currentWorkspace } = useWorkspace(); + const { createNotification } = useNotificationContext(); + + const environments = currentWorkspace?.environments || []; + useEffect(() => { + if (!isOpen) reset({}); + }, [isOpen]); + + const isEditMode = Boolean(editValues); + + const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy(); + const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy(); + + const handleCreatePolicy = async (data: TFormSchema) => { + try { + await createSecretApprovalPolicy({ + ...data, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully created policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create policy" + }); + } + }; + + const handleUpdatePolicy = async (data: TFormSchema) => { + if (!editValues?._id) return; + try { + await updateSecretApprovalPolicy({ + id: editValues?._id, + ...data, + secretPath: data.secretPath ?? "-", + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "failed to update policy" + }); + } + }; + + const handleFormSubmit = async (data: TFormSchema) => { + if (isEditMode) { + await handleUpdatePolicy(data); + } else { + await handleCreatePolicy(data); + } + }; + + return ( + + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + + + + Select members that must approve changes + {members.map(({ _id, user }) => { + const isChecked = value?.includes(_id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el) => el !== _id) + : [...(value || []), _id] + ); + }} + key={`create-policy-members-${_id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx new file mode 100644 index 000000000..f204264b4 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx @@ -0,0 +1 @@ +export { SecretApprovalPolicyList } from "./SecretApprovalPolicyList"; diff --git a/frontend/src/views/SecretApprovalPage/index.tsx b/frontend/src/views/SecretApprovalPage/index.tsx new file mode 100644 index 000000000..e45406427 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/index.tsx @@ -0,0 +1 @@ +export { SecretApprovalPage } from "./SecretApprovalPage"; From 896a34eb657a358ccdc4bcc086af230447e213e4 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Wed, 27 Sep 2023 21:07:54 -0700 Subject: [PATCH 11/30] add create service token to cli + docs for it --- cli/packages/api/api.go | 22 +++- cli/packages/api/model.go | 34 ++++++ cli/packages/cmd/tokens.go | 181 ++++++++++++++++++++++++++++ cli/packages/cmd/user.go | 2 +- cli/packages/util/secrets.go | 41 +++++++ docs/cli/commands/service-token.mdx | 73 +++++++++++ docs/mint.json | 1 + 7 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 cli/packages/cmd/tokens.go create mode 100644 docs/cli/commands/service-token.mdx diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 2307a920a..1be58985c 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -25,7 +25,7 @@ func CallGetEncryptedWorkspaceKey(httpClient *resty.Client, request GetEncrypted } if response.IsError() { - return GetEncryptedWorkspaceKeyResponse{}, fmt.Errorf("CallGetEncryptedWorkspaceKey: Unsuccessful response: [response=%s]", response) + return GetEncryptedWorkspaceKeyResponse{}, fmt.Errorf("CallGetEncryptedWorkspaceKey: Unsuccessful response [%v %v] [status-code=%v]", response.Request.Method, response.Request.URL, response.StatusCode()) } return result, nil @@ -339,3 +339,23 @@ func CallGetSingleSecretByNameV3(httpClient *resty.Client, request CreateSecretV return nil } + +func CallCreateServiceToken(httpClient *resty.Client, request CreateServiceTokenRequest) (CreateServiceTokenResponse, error) { + var createServiceTokenResponse CreateServiceTokenResponse + response, err := httpClient. + R(). + SetResult(&createServiceTokenResponse). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v2/service-token/", config.INFISICAL_URL)) + + if err != nil { + return CreateServiceTokenResponse{}, fmt.Errorf("CallCreateServiceToken: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return CreateServiceTokenResponse{}, fmt.Errorf("CallCreateServiceToken: Unsuccessful response [%v %v] [status-code=%v]", response.Request.Method, response.Request.URL, response.StatusCode()) + } + + return createServiceTokenResponse, nil +} diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 027243ef5..1531dce36 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -387,3 +387,37 @@ type GetSingleSecretByNameSecretResponse struct { UpdatedAt time.Time `json:"updatedAt"` } `json:"secrets"` } + +type ScopePermission struct { + Environment string `json:"environment"` + SecretPath string `json:"secretPath"` +} + +type CreateServiceTokenRequest struct { + Name string `json:"name"` + WorkspaceId string `json:"workspaceId"` + Scopes []ScopePermission `json:"scopes"` + ExpiresIn int `json:"expiresIn"` + EncryptedKey string `json:"encryptedKey"` + Iv string `json:"iv"` + Tag string `json:"tag"` + RandomBytes string `json:"randomBytes"` + Permissions []string `json:"permissions"` +} + +type ServiceTokenData struct { + ID string `json:"_id"` + Name string `json:"name"` + Workspace string `json:"workspace"` + Scopes []interface{} `json:"scopes"` + User string `json:"user"` + LastUsed time.Time `json:"lastUsed"` + Permissions []string `json:"permissions"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CreateServiceTokenResponse struct { + ServiceToken string `json:"serviceToken"` + ServiceTokenData ServiceTokenData `json:"serviceTokenData"` +} diff --git a/cli/packages/cmd/tokens.go b/cli/packages/cmd/tokens.go new file mode 100644 index 000000000..9f0c8da6b --- /dev/null +++ b/cli/packages/cmd/tokens.go @@ -0,0 +1,181 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/crypto" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" + "github.com/spf13/cobra" +) + +var tokensCmd = &cobra.Command{ + Use: "service-token", + Short: "Manage service tokens", + DisableFlagsInUseLine: true, + Example: "infisical service-token", + Args: cobra.ExactArgs(0), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + }, +} + +var tokensCreateCmd = &cobra.Command{ + Use: "create", + Short: "Used to create service tokens", + DisableFlagsInUseLine: true, + Example: "infisical service-token create", + Args: cobra.ExactArgs(0), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + // get plain text workspace key + loggedInUserDetails, _ := util.GetCurrentLoggedInUserDetails() + + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + tokenOnly, err := cmd.Flags().GetBool("token-only") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + workspaceId, err := cmd.Flags().GetString("projectId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if workspaceId == "" { + configFile, err := util.GetWorkSpaceFromFile() + if err != nil { + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } + workspaceId = configFile.WorkspaceId + } + + serviceTokenName, err := cmd.Flags().GetString("name") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + scopes, err := cmd.Flags().GetStringSlice("scope") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if len(scopes) == 0 { + util.PrintErrorMessageAndExit("You must define the environments and paths your service token should have access to via the --scope flag") + } + + permissions := []api.ScopePermission{} + + for _, scope := range scopes { + parts := strings.Split(scope, ":") + + if len(parts) != 2 { + fmt.Println("--scope flag is malformed. Each scope flag should be in the following format: :") + return + } + + permissions = append(permissions, api.ScopePermission{Environment: parts[0], SecretPath: parts[1]}) + } + + accessLevels, err := cmd.Flags().GetStringSlice("access-level") + if err != nil { + util.HandleError(err, "Unable to parse flag accessLevels") + } + + if len(accessLevels) == 0 { + util.PrintErrorMessageAndExit("You must define whether your service token can be used to read and or write via the --access-level flag") + } + + for _, accessLevel := range accessLevels { + if accessLevel != "read" && accessLevel != "write" { + util.PrintErrorMessageAndExit("--access-level can only be of values read and write") + } + } + + workspaceKey, err := util.GetPlainTextWorkspaceKey(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceId) + if err != nil { + fmt.Println(err) + } + + newWorkspaceEncryptionKey := make([]byte, 16) + _, err = rand.Read(newWorkspaceEncryptionKey) + if err != nil { + fmt.Println("Error generating random bytes:", err) + return + } + + newWorkspaceEncryptionKeyHexFormat := hex.EncodeToString(newWorkspaceEncryptionKey) + + // encrypt the workspace key symmetrically + encryptedDetails, err := crypto.EncryptSymmetric(workspaceKey, newWorkspaceEncryptionKey) + if err != nil { + fmt.Println(err) + } + + // make a call to the api to save the encrypted symmetric key details + httpClient := resty.New() + httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). + SetHeader("Accept", "application/json") + + createServiceTokenResponse, err := api.CallCreateServiceToken(httpClient, api.CreateServiceTokenRequest{ + Name: serviceTokenName, + WorkspaceId: workspaceId, + Scopes: permissions, + ExpiresIn: 0, + EncryptedKey: string(workspaceKey), + Iv: base64.StdEncoding.EncodeToString(encryptedDetails.Nonce), + Tag: base64.StdEncoding.EncodeToString(encryptedDetails.AuthTag), + RandomBytes: newWorkspaceEncryptionKeyHexFormat, + Permissions: accessLevels, + }) + + if err != nil { + fmt.Println(err) + } + + serviceToken := createServiceTokenResponse.ServiceToken + "." + newWorkspaceEncryptionKeyHexFormat + + if tokenOnly { + fmt.Println(serviceToken) + } else { + printablePermission := []string{} + for _, permission := range permissions { + printablePermission = append(printablePermission, fmt.Sprintf("([environment: %v] [path: %v])", permission.Environment, permission.SecretPath)) + } + + fmt.Printf("New service token created\n") + fmt.Printf("Name: %v\n", serviceTokenName) + fmt.Printf("Project ID: %v\n", workspaceId) + fmt.Printf("Access type: [%v]\n", strings.Join(accessLevels, ", ")) + fmt.Printf("Permission(s): %v\n", strings.Join(printablePermission, ", ")) + fmt.Printf("Service Token: %v\n", serviceToken) + } + }, +} + +func init() { + tokensCreateCmd.Flags().String("projectId", "", "The project ID you'd like to create the service token for. Default: will use linked Infisical project in .infisical.json") + tokensCreateCmd.Flags().StringSliceP("scope", "s", []string{}, "Environment and secret path. Example format: :") + tokensCreateCmd.Flags().StringP("name", "n", "Service token generated via CLI", "Service token name") + tokensCreateCmd.Flags().StringSliceP("access-level", "a", []string{}, "The type of access the service token should have. Can be 'read' and or 'write'") + tokensCreateCmd.Flags().Bool("token-only", false, "When true, only the service token will be printed") + + tokensCmd.AddCommand(tokensCreateCmd) + + rootCmd.AddCommand(tokensCmd) +} diff --git a/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index 0d8f1b214..96b03a0bb 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -14,7 +14,7 @@ import ( var userCmd = &cobra.Command{ Use: "user", - Short: "Used to manage user credentials", + Short: "Used to manage local user credentials", DisableFlagsInUseLine: true, Example: "infisical user", Args: cobra.ExactArgs(0), diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index a8d248a3b..73be43319 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -684,3 +684,44 @@ func GetEnvelopmentBasedOnGitBranch(workspaceFile models.WorkspaceConfigFile) st return "" } } + +func GetPlainTextWorkspaceKey(authenticationToken string, receiverPrivateKey string, workspaceId string) ([]byte, error) { + httpClient := resty.New() + httpClient.SetAuthToken(authenticationToken). + SetHeader("Accept", "application/json") + + request := api.GetEncryptedWorkspaceKeyRequest{ + WorkspaceId: workspaceId, + } + + workspaceKeyResponse, err := api.CallGetEncryptedWorkspaceKey(httpClient, request) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: unable to retrieve your encrypted workspace key. [err=%v]", err) + } + + encryptedWorkspaceKey, err := base64.StdEncoding.DecodeString(workspaceKeyResponse.EncryptedKey) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for encryptedWorkspaceKey [err=%v]", err) + } + + encryptedWorkspaceKeySenderPublicKey, err := base64.StdEncoding.DecodeString(workspaceKeyResponse.Sender.PublicKey) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for encryptedWorkspaceKeySenderPublicKey [err=%v]", err) + } + + encryptedWorkspaceKeyNonce, err := base64.StdEncoding.DecodeString(workspaceKeyResponse.Nonce) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for encryptedWorkspaceKeyNonce [err=%v]", err) + } + + currentUsersPrivateKey, err := base64.StdEncoding.DecodeString(receiverPrivateKey) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for currentUsersPrivateKey [err=%v]", err) + } + + if len(currentUsersPrivateKey) == 0 || len(encryptedWorkspaceKeySenderPublicKey) == 0 { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Missing credentials for generating plainTextEncryptionKey") + } + + return crypto.DecryptAsymmetric(encryptedWorkspaceKey, encryptedWorkspaceKeyNonce, encryptedWorkspaceKeySenderPublicKey, currentUsersPrivateKey), nil +} diff --git a/docs/cli/commands/service-token.mdx b/docs/cli/commands/service-token.mdx new file mode 100644 index 000000000..04ce5ce98 --- /dev/null +++ b/docs/cli/commands/service-token.mdx @@ -0,0 +1,73 @@ +--- +title: "infisical service-token" +description: "Manage Infisical service tokens" +--- + +```bash +infisical service-token create --scope=dev:/global --scope=dev:/backend --access-level=read --access-level=write +``` + +## Description +The Infisical `service-token` command allows you to manage service tokens for a given Infisical project. +With this command can create, view and delete service tokens. + + + Use this command to create a service token + + ```bash + $ infisical service-token create --scope=dev:/backend/** --access-level=read --access-level=write + ``` + + ### Flags + + ```bash + infisical service-token create --scope=dev:/global --scope=dev:/backend/** --access-level=read + ``` + + Use the scope flag to define which environments and paths your service token should be authorized to access. + + The value of your scope flag should be in the following `:`. + Here, `environment slug` refers to the slug name of the environment, and `path` indicates the folder path where your secrets are stored. + + For specifying multiple scopes, you can use multiple --scope flags. + + + The `path` can be a Glob pattern + + + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --projectId=63cefb15c8d3175601cfa989 + ``` + + The project ID you'd like to create the service token for. + By default, the CLI will attempt to use the linked Infisical project in `.infisical.json` generated by `infisical init` command. + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --name service-token-name + ``` + + Service token name + + Default: `Service token generated via CLI` + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --access-level=write + ``` + + The type of access the service token should have. Can be `read` and or `write` + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --access-level=write --token-only + ``` + + When true, only the service token will be printed + + Default: `false` + + + \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 260b174b0..08588eded 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -169,6 +169,7 @@ "cli/commands/run", "cli/commands/secrets", "cli/commands/export", + "cli/commands/service-token", "cli/commands/vault", "cli/commands/user", "cli/commands/reset", From b24bff5af6b5b28ea20c5ed9339b291b2b731522 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Wed, 27 Sep 2023 21:17:28 -0700 Subject: [PATCH 12/30] Update service-token.mdx --- docs/cli/commands/service-token.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/cli/commands/service-token.mdx b/docs/cli/commands/service-token.mdx index 04ce5ce98..e7e896523 100644 --- a/docs/cli/commands/service-token.mdx +++ b/docs/cli/commands/service-token.mdx @@ -69,5 +69,4 @@ With this command can create, view and delete service tokens. Default: `false` - - \ No newline at end of file + From 1141408d5b96a61ecc8e6b82350ca23c5cd07fa3 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Wed, 27 Sep 2023 21:42:25 -0700 Subject: [PATCH 13/30] add exit codes for errors --- cli/packages/cmd/tokens.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/cli/packages/cmd/tokens.go b/cli/packages/cmd/tokens.go index 9f0c8da6b..fecd0b055 100644 --- a/cli/packages/cmd/tokens.go +++ b/cli/packages/cmd/tokens.go @@ -41,7 +41,11 @@ var tokensCreateCmd = &cobra.Command{ }, Run: func(cmd *cobra.Command, args []string) { // get plain text workspace key - loggedInUserDetails, _ := util.GetCurrentLoggedInUserDetails() + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() + + if err != nil { + util.HandleError(err, "Unable to retrieve your logged in your details. Please login in then try again") + } if loggedInUserDetails.LoginExpired { util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") @@ -109,14 +113,13 @@ var tokensCreateCmd = &cobra.Command{ workspaceKey, err := util.GetPlainTextWorkspaceKey(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceId) if err != nil { - fmt.Println(err) + util.HandleError(err, "Unable to get workspace key needed to create service token") } newWorkspaceEncryptionKey := make([]byte, 16) _, err = rand.Read(newWorkspaceEncryptionKey) if err != nil { - fmt.Println("Error generating random bytes:", err) - return + util.HandleError(err) } newWorkspaceEncryptionKeyHexFormat := hex.EncodeToString(newWorkspaceEncryptionKey) @@ -124,7 +127,7 @@ var tokensCreateCmd = &cobra.Command{ // encrypt the workspace key symmetrically encryptedDetails, err := crypto.EncryptSymmetric(workspaceKey, newWorkspaceEncryptionKey) if err != nil { - fmt.Println(err) + util.HandleError(err) } // make a call to the api to save the encrypted symmetric key details @@ -145,7 +148,7 @@ var tokensCreateCmd = &cobra.Command{ }) if err != nil { - fmt.Println(err) + util.HandleError(err, "Unable to create service token") } serviceToken := createServiceTokenResponse.ServiceToken + "." + newWorkspaceEncryptionKeyHexFormat From b0c398688bc8bf584d3af8fdbdd338edaaf9b315 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Thu, 28 Sep 2023 12:21:37 +0530 Subject: [PATCH 14/30] feat(secret-approval): updated names to secret policy and fixed approval number bug --- backend/src/controllers/v1/index.ts | 4 ++-- ...r.ts => secretApprovalPolicyController.ts} | 24 +++++++++---------- backend/src/index.ts | 4 ++-- ...retApproval.ts => secretApprovalPolicy.ts} | 9 ++++--- backend/src/models/secretApprovalRequest.ts | 7 ++++-- backend/src/routes/v1/index.ts | 4 ++-- ...retApproval.ts => secretApprovalPolicy.ts} | 10 ++++---- backend/src/validation/secretApproval.ts | 4 ++-- .../src/hooks/api/secretApproval/types.ts | 4 ++-- .../SecretApprovalPolicyList.tsx | 9 ++++++- .../components/SecretPolicyForm.tsx | 12 ++++++---- 11 files changed, 54 insertions(+), 37 deletions(-) rename backend/src/controllers/v1/{secretApprovalController.ts => secretApprovalPolicyController.ts} (73%) rename backend/src/models/{secretApproval.ts => secretApprovalPolicy.ts} (74%) rename backend/src/routes/v1/{secretApproval.ts => secretApprovalPolicy.ts} (62%) diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index 9dcbb12c2..c2bb1b94d 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -16,7 +16,7 @@ import * as workspaceController from "./workspaceController"; import * as secretScanningController from "./secretScanningController"; import * as webhookController from "./webhookController"; import * as secretImpsController from "./secretImpsController"; -import * as secretApprovalController from "./secretApprovalController"; +import * as secretApprovalPolicyController from "./secretApprovalPolicyController"; export { authController, @@ -37,5 +37,5 @@ export { secretScanningController, webhookController, secretImpsController, - secretApprovalController + secretApprovalPolicyController }; diff --git a/backend/src/controllers/v1/secretApprovalController.ts b/backend/src/controllers/v1/secretApprovalPolicyController.ts similarity index 73% rename from backend/src/controllers/v1/secretApprovalController.ts rename to backend/src/controllers/v1/secretApprovalPolicyController.ts index 8441a93b0..245932353 100644 --- a/backend/src/controllers/v1/secretApprovalController.ts +++ b/backend/src/controllers/v1/secretApprovalPolicyController.ts @@ -6,13 +6,13 @@ import { getUserProjectPermissions } from "../../ee/services/ProjectRoleService"; import { validateRequest } from "../../helpers/validation"; -import { SecretApproval } from "../../models/secretApproval"; +import { SecretApprovalPolicy } from "../../models/secretApprovalPolicy"; import { BadRequestError } from "../../utils/errors"; import * as reqValidator from "../../validation/secretApproval"; const ERR_SECRET_APPROVAL_NOT_FOUND = BadRequestError({ message: "secret approval not found" }); -export const createSecretApprovalRule = async (req: Request, res: Response) => { +export const createSecretApprovalPolicy = async (req: Request, res: Response) => { const { body: { approvals, secretPath, approvers, environment, workspaceId } } = await validateRequest(reqValidator.CreateSecretApprovalRule, req); @@ -23,7 +23,7 @@ export const createSecretApprovalRule = async (req: Request, res: Response) => { ProjectPermissionSub.SecretApproval ); - const secretApproval = new SecretApproval({ + const secretApproval = new SecretApprovalPolicy({ workspace: workspaceId, secretPath, environment, @@ -37,13 +37,13 @@ export const createSecretApprovalRule = async (req: Request, res: Response) => { }); }; -export const updateSecretApprovalRule = async (req: Request, res: Response) => { +export const updateSecretApprovalPolicy = async (req: Request, res: Response) => { const { body: { approvals, approvers, secretPath }, params: { id } } = await validateRequest(reqValidator.UpdateSecretApprovalRule, req); - const secretApproval = await SecretApproval.findById(id); + const secretApproval = await SecretApprovalPolicy.findById(id); if (!secretApproval) throw ERR_SECRET_APPROVAL_NOT_FOUND; const { permission } = await getUserProjectPermissions( @@ -55,10 +55,10 @@ export const updateSecretApprovalRule = async (req: Request, res: Response) => { ProjectPermissionSub.SecretApproval ); - const updatedDoc = await SecretApproval.findByIdAndUpdate(id, { + const updatedDoc = await SecretApprovalPolicy.findByIdAndUpdate(id, { approvals, approvers, - $set: secretPath === "-" ? undefined : { secretPath } + ...(secretPath === null ? { $unset: { secretPath: 1 } } : { secretPath }) }); return res.send({ @@ -66,12 +66,12 @@ export const updateSecretApprovalRule = async (req: Request, res: Response) => { }); }; -export const deleteSecretApprovalRule = async (req: Request, res: Response) => { +export const deleteSecretApprovalPolicy = async (req: Request, res: Response) => { const { params: { id } } = await validateRequest(reqValidator.DeleteSecretApprovalRule, req); - const secretApproval = await SecretApproval.findById(id); + const secretApproval = await SecretApprovalPolicy.findById(id); if (!secretApproval) throw ERR_SECRET_APPROVAL_NOT_FOUND; const { permission } = await getUserProjectPermissions( @@ -83,14 +83,14 @@ export const deleteSecretApprovalRule = async (req: Request, res: Response) => { ProjectPermissionSub.SecretApproval ); - const deletedDoc = await SecretApproval.findByIdAndDelete(id); + const deletedDoc = await SecretApprovalPolicy.findByIdAndDelete(id); return res.send({ approval: deletedDoc }); }; -export const getSecretApprovalRules = async (req: Request, res: Response) => { +export const getSecretApprovalPolicy = async (req: Request, res: Response) => { const { query: { workspaceId } } = await validateRequest(reqValidator.GetSecretApprovalRuleList, req); @@ -101,7 +101,7 @@ export const getSecretApprovalRules = async (req: Request, res: Response) => { ProjectPermissionSub.SecretApproval ); - const doc = await SecretApproval.find({ workspace: workspaceId }); + const doc = await SecretApprovalPolicy.find({ workspace: workspaceId }); return res.send({ approvals: doc diff --git a/backend/src/index.ts b/backend/src/index.ts index c3aed4b7b..2a5159738 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -38,7 +38,7 @@ import { membership as v1MembershipRouter, organization as v1OrganizationRouter, password as v1PasswordRouter, - secretApproval as v1SecretApproval, + secretApprovalPolicy as v1SecretApprovalPolicy, secretImps as v1SecretImpsRouter, secret as v1SecretRouter, secretsFolder as v1SecretsFolder, @@ -177,7 +177,7 @@ const main = async () => { app.use("/api/v1/webhooks", v1WebhooksRouter); app.use("/api/v1/secret-imports", v1SecretImpsRouter); app.use("/api/v1/roles", v1RoleRouter); - app.use("/api/v1/secret-approvals", v1SecretApproval); + app.use("/api/v1/secret-approvals", v1SecretApprovalPolicy); // v2 routes (improvements) app.use("/api/v2/signup", v2SignupRouter); diff --git a/backend/src/models/secretApproval.ts b/backend/src/models/secretApprovalPolicy.ts similarity index 74% rename from backend/src/models/secretApproval.ts rename to backend/src/models/secretApprovalPolicy.ts index f4e2fc92e..9471f82db 100644 --- a/backend/src/models/secretApproval.ts +++ b/backend/src/models/secretApprovalPolicy.ts @@ -1,6 +1,6 @@ import { Schema, Types, model } from "mongoose"; -export interface ISecretApproval { +export interface ISecretApprovalPolicy { _id: Types.ObjectId; workspace: Types.ObjectId; environment: string; @@ -9,7 +9,7 @@ export interface ISecretApproval { approvals: number; } -const secretApprovalSchema = new Schema( +const secretApprovalPolicySchema = new Schema( { workspace: { type: Schema.Types.ObjectId, @@ -41,4 +41,7 @@ const secretApprovalSchema = new Schema( } ); -export const SecretApproval = model("SecretApproval", secretApprovalSchema); +export const SecretApprovalPolicy = model( + "SecretApprovalPolicy", + secretApprovalPolicySchema +); diff --git a/backend/src/models/secretApprovalRequest.ts b/backend/src/models/secretApprovalRequest.ts index 4133f06a7..a145b5773 100644 --- a/backend/src/models/secretApprovalRequest.ts +++ b/backend/src/models/secretApprovalRequest.ts @@ -30,7 +30,7 @@ export interface ISecretApprovalRequest { }[]; } -const secretApprovalSchema = new Schema( +const secretApprovalRequestSchema = new Schema( { approvers: [ { @@ -62,4 +62,7 @@ const secretApprovalSchema = new Schema( } ); -export const SecretApproval = model("SecretApproval", secretApprovalSchema); +export const SecretApprovalRequest = model( + "SecretApprovalRequest", + secretApprovalRequestSchema +); diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 7c0f94972..cfdccdc92 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -17,7 +17,7 @@ import integrationAuth from "./integrationAuth"; import secretsFolder from "./secretsFolder"; import webhooks from "./webhook"; import secretImps from "./secretImps"; -import secretApproval from "./secretApproval"; +import secretApprovalPolicy from "./secretApprovalPolicy"; export { signup, @@ -39,5 +39,5 @@ export { secretsFolder, webhooks, secretImps, - secretApproval + secretApprovalPolicy }; diff --git a/backend/src/routes/v1/secretApproval.ts b/backend/src/routes/v1/secretApprovalPolicy.ts similarity index 62% rename from backend/src/routes/v1/secretApproval.ts rename to backend/src/routes/v1/secretApprovalPolicy.ts index 213bb3cc3..51c2aa3e7 100644 --- a/backend/src/routes/v1/secretApproval.ts +++ b/backend/src/routes/v1/secretApprovalPolicy.ts @@ -1,7 +1,7 @@ import express from "express"; const router = express.Router(); import { requireAuth } from "../../middleware"; -import { secretApprovalController } from "../../controllers/v1"; +import { secretApprovalPolicyController } from "../../controllers/v1"; import { AuthMode } from "../../variables"; router.get( @@ -9,7 +9,7 @@ router.get( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - secretApprovalController.getSecretApprovalRules + secretApprovalPolicyController.getSecretApprovalPolicy ); router.post( @@ -17,7 +17,7 @@ router.post( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - secretApprovalController.createSecretApprovalRule + secretApprovalPolicyController.createSecretApprovalPolicy ); router.patch( @@ -25,7 +25,7 @@ router.patch( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - secretApprovalController.updateSecretApprovalRule + secretApprovalPolicyController.updateSecretApprovalPolicy ); router.delete( @@ -33,7 +33,7 @@ router.delete( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - secretApprovalController.deleteSecretApprovalRule + secretApprovalPolicyController.deleteSecretApprovalPolicy ); export default router; diff --git a/backend/src/validation/secretApproval.ts b/backend/src/validation/secretApproval.ts index a25da81b2..9d4b10ce5 100644 --- a/backend/src/validation/secretApproval.ts +++ b/backend/src/validation/secretApproval.ts @@ -10,7 +10,7 @@ export const CreateSecretApprovalRule = z.object({ body: z.object({ workspaceId: z.string(), environment: z.string(), - secretPath: z.string().optional(), + secretPath: z.string().optional().nullable(), approvers: z.string().array().optional(), approvals: z.number().min(1).default(1) }) @@ -23,7 +23,7 @@ export const UpdateSecretApprovalRule = z.object({ body: z.object({ approvers: z.string().array().optional(), approvals: z.number().min(1).optional(), - secretPath: z.string().optional() + secretPath: z.string().optional().nullable() }) }); diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index b77fc1a91..8eb486664 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -10,7 +10,7 @@ export type TSecretApprovalPolicy = { export type TCreateSecretPolicyDTO = { workspaceId: string; environment: string; - secretPath?: string; + secretPath?: string | null; approvers?: string[]; approvals?: number; }; @@ -18,7 +18,7 @@ export type TCreateSecretPolicyDTO = { export type TUpdateSecretPolicyDTO = { id: string; approvers?: string[]; - secretPath?: string; + secretPath?: string | null; approvals?: number; // for invalidating list workspaceId: string; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx index dcc27e49f..700b1a785 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx @@ -1,14 +1,16 @@ -import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Button, DeleteActionModal, + EmptyState, Table, TableContainer, TableSkeleton, TBody, + Td, Th, THead, Tr @@ -96,6 +98,11 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => { {isPoliciesLoading && ( )} + {!isPoliciesLoading && !policies?.length && ( + + + + )} {policies?.map((policy) => ( ( - + )} /> @@ -220,7 +220,11 @@ export const SecretPolicyForm = ({ isError={Boolean(error)} errorText={error?.message} > - + field.onChange(parseInt(el.target.value, 10))} + /> )} /> From 0ada343b6f7d74dd409af0ed696ccab01dabb7a5 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 22 Sep 2023 21:48:53 +0530 Subject: [PATCH 15/30] feat(dashboard-v3): migrated folder, imports and snapshots to use only secret path and not folder id --- .../controllers/v1/secretImpsController.ts | 167 +++-- .../controllers/v1/secretsFolderController.ts | 233 +++---- .../src/controllers/v3/secretsController.ts | 151 ++++- .../ee/controllers/v1/workspaceController.ts | 42 +- backend/src/ee/models/auditLog/enums.ts | 83 +-- backend/src/ee/models/auditLog/types.ts | 611 ++++++++--------- backend/src/ee/models/secretVersion.ts | 50 +- backend/src/helpers/secrets.ts | 613 +++++++++++++++++- .../services/SecretService/index.ts | 64 ++ backend/src/models/secret.ts | 59 +- backend/src/routes/v1/secretsFolder.ts | 4 +- backend/src/routes/v3/secrets.ts | 44 +- backend/src/services/SecretImportService.ts | 8 - backend/src/services/SecretService.ts | 72 +- backend/src/validation/folders.ts | 15 +- backend/src/validation/secretImports.ts | 6 +- backend/src/validation/secrets.ts | 81 ++- backend/src/validation/workspace.ts | 13 +- backend/src/variables/secret.ts | 2 +- 19 files changed, 1607 insertions(+), 711 deletions(-) diff --git a/backend/src/controllers/v1/secretImpsController.ts b/backend/src/controllers/v1/secretImpsController.ts index a16d666c4..956229b7c 100644 --- a/backend/src/controllers/v1/secretImpsController.ts +++ b/backend/src/controllers/v1/secretImpsController.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { isValidScope } from "../../helpers"; import { Folder, IServiceTokenData, SecretImport, ServiceTokenData } from "../../models"; import { getAllImportedSecrets } from "../../services/SecretImportService"; -import { getFolderWithPathFromId } from "../../services/FolderService"; +import { getFolderByPath, getFolderWithPathFromId } from "../../services/FolderService"; import { BadRequestError, ResourceNotFoundError, @@ -95,37 +95,12 @@ export const createSecretImp = async (req: Request, res: Response) => { */ const { - body: { workspaceId, environment, folderId, secretImport } + body: { workspaceId, environment, directory, secretImport } } = await validateRequest(reqValidator.CreateSecretImportV1, req); - const folders = await Folder.findOne({ - workspace: workspaceId, - environment - }).lean(); - - if (!folders && folderId !== "root") { - throw ResourceNotFoundError({ - message: "Failed to find folder" - }); - } - - let secretPath = "/"; - if (folders) { - const { folderPath } = getFolderWithPathFromId(folders.nodes, folderId); - secretPath = folderPath; - } - if (req.authData.authPayload instanceof ServiceTokenData) { // root check - let isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - isValidScopeAccess = isValidScope( - req.authData.authPayload, - secretImport.environment, - secretImport.secretPath - ); + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); if (!isValidScopeAccess) { throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); } @@ -133,27 +108,31 @@ export const createSecretImp = async (req: Request, res: Response) => { const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: secretImport.environment, - secretPath: secretImport.secretPath - }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) ); } + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }).lean(); + + if (!folders && directory !== "/") + throw ResourceNotFoundError({ message: "Failed to find folder" }); + + let folderId = "root"; + if (folders) { + const folder = getFolderByPath(folders.nodes, directory); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + folderId = folder.id; + } + const importSecDoc = await SecretImport.findOne({ workspace: workspaceId, environment, folderId }); - const importToSecretPath = folders - ? getFolderWithPathFromId(folders.nodes, folderId).folderPath - : "/"; - if (!importSecDoc) { const doc = new SecretImport({ workspace: workspaceId, @@ -173,7 +152,7 @@ export const createSecretImp = async (req: Request, res: Response) => { importFromEnvironment: secretImport.environment, importFromSecretPath: secretImport.secretPath, importToEnvironment: environment, - importToSecretPath + importToSecretPath: directory } }, { @@ -206,7 +185,7 @@ export const createSecretImp = async (req: Request, res: Response) => { importFromEnvironment: secretImport.environment, importFromSecretPath: secretImport.secretPath, importToEnvironment: environment, - importToSecretPath + importToSecretPath: directory } }, { @@ -563,8 +542,38 @@ export const getSecretImports = async (req: Request, res: Response) => { } */ const { - query: { workspaceId, environment, folderId } + query: { workspaceId, environment, directory } } = await validateRequest(reqValidator.GetSecretImportsV1, req); + + if (req.authData.authPayload instanceof ServiceTokenData) { + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath: directory + }) + ); + } + + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }).lean(); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + let folderId = "root"; + if (folders) { + const folder = getFolderByPath(folders.nodes, directory); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + folderId = folder.id; + } + const importSecDoc = await SecretImport.findOne({ workspace: workspaceId, environment, @@ -575,41 +584,6 @@ export const getSecretImports = async (req: Request, res: Response) => { return res.status(200).json({ secretImport: {} }); } - // check for service token validity - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment - }).lean(); - - let secretPath = "/"; - if (folders) { - const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); - secretPath = folderPath; - } - - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - importSecDoc.environment, - secretPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } else { - const { permission } = await getUserProjectPermissions( - req.user._id, - importSecDoc.workspace.toString() - ); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: importSecDoc.environment, - secretPath - }) - ); - } - return res.status(200).json({ secretImport: importSecDoc }); }; @@ -621,9 +595,39 @@ export const getSecretImports = async (req: Request, res: Response) => { */ export const getAllSecretsFromImport = async (req: Request, res: Response) => { const { - query: { workspaceId, environment, folderId } + query: { workspaceId, environment, directory } } = await validateRequest(reqValidator.GetAllSecretsFromImportV1, req); + if (req.authData.authPayload instanceof ServiceTokenData) { + // check for service token validity + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath: directory + }) + ); + } + + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }).lean(); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + let folderId = "root"; + if (folders) { + const folder = getFolderByPath(folders.nodes, directory); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + folderId = folder.id; + } + const importSecDoc = await SecretImport.findOne({ workspace: workspaceId, environment, @@ -634,11 +638,6 @@ export const getAllSecretsFromImport = async (req: Request, res: Response) => { return res.status(200).json({ secrets: [] }); } - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment - }).lean(); - let secretPath = "/"; if (folders) { const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); diff --git a/backend/src/controllers/v1/secretsFolderController.ts b/backend/src/controllers/v1/secretsFolderController.ts index b6f60249c..d030bce9a 100644 --- a/backend/src/controllers/v1/secretsFolderController.ts +++ b/backend/src/controllers/v1/secretsFolderController.ts @@ -9,12 +9,10 @@ import { Secret, ServiceTokenData } from "../../models"; import { Folder } from "../../models/folder"; import { appendFolder, - deleteFolderById, generateFolderId, getAllFolderIds, getFolderByPath, getFolderWithPathFromId, - getParentFromFolderId, validateFolderName } from "../../services/FolderService"; import { @@ -25,13 +23,9 @@ import { import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; import * as reqValidator from "../../validation/folders"; -/** - * Create folder with name [folderName] for workspace with id [workspaceId] - * and environment [environment] - * @param req - * @param res - * @returns - */ +const ERR_FOLDER_NOT_FOUND = BadRequestError({ message: "The folder doesn't exist" }); + +// verify workspace id/environment export const createFolder = async (req: Request, res: Response) => { /* #swagger.summary = 'Create a folder' @@ -107,7 +101,7 @@ export const createFolder = async (req: Request, res: Response) => { } */ const { - body: { workspaceId, environment, folderName, parentFolderId } + body: { workspaceId, environment, folderName, directory } } = await validateRequest(reqValidator.CreateFolderV1, req); if (!validateFolderName(folderName)) { @@ -116,33 +110,29 @@ export const createFolder = async (req: Request, res: Response) => { }); } + if (req.authData.authPayload instanceof ServiceTokenData) { + // token check + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + // user check + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) + ); + } + const folders = await Folder.findOne({ workspace: workspaceId, environment }).lean(); - if (req.user) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - const secretPath = - folders && parentFolderId - ? getFolderWithPathFromId(folders.nodes, parentFolderId).folderPath - : "/"; - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } - // space has no folders initialized - if (!folders) { - if (req.authData.authPayload instanceof ServiceTokenData) { - // root check - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, "/"); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } + if (directory !== "/") throw ERR_FOLDER_NOT_FOUND; const id = generateFolderId(); const folder = new Folder({ @@ -186,27 +176,10 @@ export const createFolder = async (req: Request, res: Response) => { return res.json({ folder: { id, name: folderName } }); } - const folder = appendFolder(folders.nodes, { folderName, parentFolderId }); - - await Folder.findByIdAndUpdate(folders._id, folders); - - const { folder: parentFolder, folderPath: parentFolderPath } = getFolderWithPathFromId( - folders.nodes, - parentFolderId || "root" - ); - - if (req.authData.authPayload instanceof ServiceTokenData) { - // root check - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - environment, - parentFolderPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } + const parentFolder = getFolderByPath(folders.nodes, directory); + if (!parentFolder) throw ERR_FOLDER_NOT_FOUND; + const folder = appendFolder(folders.nodes, { folderName, parentFolderId: parentFolder.id }); await Folder.findByIdAndUpdate(folders._id, folders); const folderVersion = new FolderVersion({ @@ -219,11 +192,9 @@ export const createFolder = async (req: Request, res: Response) => { await EESecretService.takeSecretSnapshot({ workspaceId: new Types.ObjectId(workspaceId), environment, - folderId: parentFolderId + folderId: parentFolder.id }); - const { folderPath } = getFolderWithPathFromId(folders.nodes, folder.id); - await EEAuditLogService.createAuditLog( req.authData, { @@ -232,7 +203,7 @@ export const createFolder = async (req: Request, res: Response) => { environment, folderId: folder.id, folderName, - folderPath + folderPath: directory } }, { @@ -332,8 +303,8 @@ export const updateFolderById = async (req: Request, res: Response) => { } */ const { - body: { workspaceId, environment, name }, - params: { folderId } + body: { workspaceId, environment, name, directory }, + params: { folderName } } = await validateRequest(reqValidator.UpdateFolderV1, req); if (!validateFolderName(name)) { @@ -342,38 +313,31 @@ export const updateFolderById = async (req: Request, res: Response) => { }); } + if (req.authData.authPayload instanceof ServiceTokenData) { + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) + ); + } + const folders = await Folder.findOne({ workspace: workspaceId, environment }); if (!folders) { throw BadRequestError({ message: "The folder doesn't exist" }); } - const parentFolder = getParentFromFolderId(folders.nodes, folderId); + const parentFolder = getFolderByPath(folders.nodes, directory); if (!parentFolder) { throw BadRequestError({ message: "The folder doesn't exist" }); } - if (req.user) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - const secretPath = getFolderWithPathFromId(folders.nodes, parentFolder.id).folderPath; - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } - - const folder = parentFolder.children.find(({ id }) => id === folderId); - if (!folder) { - throw BadRequestError({ message: "The folder doesn't exist" }); - } - - if (req.authData.authPayload instanceof ServiceTokenData) { - const { folderPath: secretPath } = getFolderWithPathFromId(folders.nodes, parentFolder.id); - // root check - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } + const folder = parentFolder.children.find(({ name }) => name === folderName); + if (!folder) throw ERR_FOLDER_NOT_FOUND; const oldFolderName = folder.name; parentFolder.version += 1; @@ -505,24 +469,12 @@ export const deleteFolder = async (req: Request, res: Response) => { } */ const { - params: { folderId }, - body: { environment, workspaceId } + params: { folderName }, + body: { environment, workspaceId, directory } } = await validateRequest(reqValidator.DeleteFolderV1, req); - const folders = await Folder.findOne({ workspace: workspaceId, environment }); - if (!folders) { - throw BadRequestError({ message: "The folder doesn't exist" }); - } - - const delOp = deleteFolderById(folders.nodes, folderId); - if (!delOp) { - throw BadRequestError({ message: "The folder doesn't exist" }); - } - const { deletedNode: delFolder, parent: parentFolder } = delOp; - const { folderPath: secretPath } = getFolderWithPathFromId(folders.nodes, parentFolder.id); - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); if (!isValidScopeAccess) { throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); } @@ -531,12 +483,23 @@ export const deleteFolder = async (req: Request, res: Response) => { const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) ); } + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders) throw ERR_FOLDER_NOT_FOUND; + + const parentFolder = getFolderByPath(folders.nodes, directory); + if (!parentFolder) throw ERR_FOLDER_NOT_FOUND; + + const index = parentFolder.children.findIndex(({ name }) => name === folderName); + if (index === -1) throw ERR_FOLDER_NOT_FOUND; + + const deletedFolder = parentFolder.children.splice(index, 1)[0]; + parentFolder.version += 1; - const delFolderIds = getAllFolderIds(delFolder); + const delFolderIds = getAllFolderIds(deletedFolder); await Folder.findByIdAndUpdate(folders._id, folders); const folderVersion = new FolderVersion({ @@ -565,9 +528,9 @@ export const deleteFolder = async (req: Request, res: Response) => { type: EventType.DELETE_FOLDER, metadata: { environment, - folderId, - folderName: delFolder.name, - folderPath: secretPath + folderId: deletedFolder.id, + folderName: deletedFolder.name, + folderPath: directory } }, { @@ -575,7 +538,7 @@ export const deleteFolder = async (req: Request, res: Response) => { } ); - res.send({ message: "successfully deleted folders", folders: delFolderIds }); + return res.send({ message: "successfully deleted folders", folders: delFolderIds }); }; /** @@ -677,69 +640,27 @@ export const getFolders = async (req: Request, res: Response) => { } */ const { - query: { workspaceId, environment, parentFolderId, parentFolderPath } + query: { workspaceId, environment, directory } } = await validateRequest(reqValidator.GetFoldersV1, req); - const folders = await Folder.findOne({ workspace: workspaceId, environment }); - - if (req.user) await getUserProjectPermissions(req.user._id, workspaceId); - - if (!folders) { - res.send({ folders: [], dir: [] }); - return; - } - - // if instead of parentFolderId given a path like /folder1/folder2 - if (parentFolderPath) { - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - environment, - parentFolderPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - const folder = getFolderByPath(folders.nodes, parentFolderPath); - - if (!folder) { - res.send({ folders: [], dir: [] }); - return; - } - // dir is not needed at present as this is only used in overview section of secrets - res.send({ - folders: folder.children.map(({ id, name }) => ({ id, name })), - dir: [{ name: folder.name, id: folder.id }] - }); - } - - if (!parentFolderId) { - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, "/"); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - - const rootFolders = folders.nodes.children.map(({ id, name }) => ({ - id, - name - })); - res.send({ folders: rootFolders }); - return; - } - - const { folder, folderPath, dir } = getFolderWithPathFromId(folders.nodes, parentFolderId); if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, folderPath); + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); if (!isValidScopeAccess) { throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); } + } else { + // check that user is a member of the workspace + await getUserProjectPermissions(req.user._id, workspaceId); } - res.send({ - folders: folder.children.map(({ id, name }) => ({ id, name })), - dir + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders) { + return res.send({ folders: [], dir: [] }); + } + + const folder = getFolderByPath(folders.nodes, directory); + + return res.send({ + folders: folder?.children?.map(({ id, name }) => ({ id, name })) || [] }); }; diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index a553315bd..451de1294 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -196,7 +196,15 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => { export const createSecretRaw = async (req: Request, res: Response) => { const { params: { secretName }, - body: { secretPath, environment, workspaceId, type, secretValue, secretComment } + body: { + secretPath, + environment, + workspaceId, + type, + secretValue, + secretComment, + skipMultilineEncoding + } } = await validateRequest(reqValidator.CreateSecretRawV3, req); if (req.user?._id) { @@ -249,7 +257,8 @@ export const createSecretRaw = async (req: Request, res: Response) => { secretPath, secretCommentCiphertext: secretCommentEncrypted.ciphertext, secretCommentIV: secretCommentEncrypted.iv, - secretCommentTag: secretCommentEncrypted.tag + secretCommentTag: secretCommentEncrypted.tag, + skipMultilineEncoding }); await EventService.handleEvent({ @@ -279,7 +288,7 @@ export const createSecretRaw = async (req: Request, res: Response) => { export const updateSecretByNameRaw = async (req: Request, res: Response) => { const { params: { secretName }, - body: { secretValue, environment, secretPath, type, workspaceId } + body: { secretValue, environment, secretPath, type, workspaceId, skipMultilineEncoding } } = await validateRequest(reqValidator.UpdateSecretByNameRawV3, req); if (req.user?._id) { @@ -316,7 +325,8 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => { secretValueCiphertext: secretValueEncrypted.ciphertext, secretValueIV: secretValueEncrypted.iv, secretValueTag: secretValueEncrypted.tag, - secretPath + secretPath, + skipMultilineEncoding }); await EventService.handleEvent({ @@ -540,7 +550,8 @@ export const createSecret = async (req: Request, res: Response) => { secretCommentTag, secretKeyCiphertext, secretValueCiphertext, - secretCommentCiphertext + secretCommentCiphertext, + skipMultilineEncoding }, params: { secretName } } = await validateRequest(reqValidator.CreateSecretV3, req); @@ -577,7 +588,8 @@ export const createSecret = async (req: Request, res: Response) => { secretCommentCiphertext, secretCommentIV, secretCommentTag, - metadata + metadata, + skipMultilineEncoding }); await EventService.handleEvent({ @@ -610,11 +622,23 @@ export const updateSecretByName = async (req: Request, res: Response) => { type, environment, secretPath, - workspaceId + workspaceId, + tags, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + secretName: newSecretName, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + skipMultilineEncoding }, params: { secretName } } = await validateRequest(reqValidator.UpdateSecretByNameV3, req); + if (newSecretName && (!secretKeyIV || !secretKeyTag || !secretKeyCiphertext)) + throw BadRequestError({ message: "Missing encrypted key" }); + if (req.user?._id) { const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); ForbiddenError.from(permission).throwUnlessCan( @@ -637,10 +661,19 @@ export const updateSecretByName = async (req: Request, res: Response) => { environment, type, authData: req.authData, + newSecretName, secretValueCiphertext, secretValueIV, secretValueTag, - secretPath + secretPath, + tags, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretKeyTag, + secretKeyCiphertext, + secretKeyIV }); await EventService.handleEvent({ @@ -704,3 +737,105 @@ export const deleteSecretByName = async (req: Request, res: Response) => { secret }); }; + +export const createSecretByNameBatch = async (req: Request, res: Response) => { + const { + body: { secrets, secretPath, environment, workspaceId } + } = await validateRequest(reqValidator.CreateSecretByNameBatchV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } + + const createdSecrets = await SecretService.createSecretBatch({ + secretPath, + environment, + workspaceId: new Types.ObjectId(workspaceId), + secrets, + authData: req.authData + }); + + return res.status(200).send({ + secrets: createdSecrets + }); +}; + +export const updateSecretByNameBatch = async (req: Request, res: Response) => { + const { + body: { secrets, secretPath, environment, workspaceId } + } = await validateRequest(reqValidator.UpdateSecretByNameBatchV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } + + const updatedSecrets = await SecretService.updateSecretBatch({ + secretPath, + environment, + workspaceId: new Types.ObjectId(workspaceId), + secrets, + authData: req.authData + }); + + return res.status(200).send({ + secrets: updatedSecrets + }); +}; + +export const deleteSecretByNameBatch = async (req: Request, res: Response) => { + const { + body: { secrets, secretPath, environment, workspaceId } + } = await validateRequest(reqValidator.DeleteSecretByNameBatchV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } + + const deletedSecrets = await SecretService.deleteSecretBatch({ + secretPath, + environment, + workspaceId: new Types.ObjectId(workspaceId), + secrets, + authData: req.authData + }); + + return res.status(200).send({ + secrets: deletedSecrets + }); +}; diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 7c9502656..cef139c5c 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -27,7 +27,7 @@ import { import { EESecretService } from "../../services"; import { getLatestSecretVersionIds } from "../../helpers/secretVersion"; // import Folder, { TFolderSchema } from "../../../models/folder"; -import { searchByFolderId } from "../../../services/FolderService"; +import { getFolderByPath, searchByFolderId } from "../../../services/FolderService"; import { EEAuditLogService, EELicenseService } from "../../services"; import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; import { validateRequest } from "../../../helpers/validation"; @@ -104,7 +104,7 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) = */ const { params: { workspaceId }, - query: { environment, folderId, offset, limit } + query: { environment, directory, offset, limit } } = await validateRequest(GetWorkspaceSecretSnapshotsV1, req); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); @@ -113,10 +113,20 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) = ProjectPermissionSub.SecretRollback ); + let folderId = "root"; + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + if (folders) { + const folder = getFolderByPath(folders?.nodes, directory); + if (!folder) throw BadRequestError({ message: "Invalid folder id" }); + folderId = folder.id; + } + const secretSnapshots = await SecretSnapshot.find({ workspace: workspaceId, environment, - folderId: folderId || "root" + folderId }) .sort({ createdAt: -1 }) .skip(offset) @@ -135,7 +145,7 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) = export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Response) => { const { params: { workspaceId }, - query: { environment, folderId } + query: { environment, directory } } = await validateRequest(GetWorkspaceSecretSnapshotsCountV1, req); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); @@ -144,10 +154,20 @@ export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Respon ProjectPermissionSub.SecretRollback ); + let folderId = "root"; + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + if (folders) { + const folder = getFolderByPath(folders?.nodes, directory); + if (!folder) throw BadRequestError({ message: "Invalid folder id" }); + folderId = folder.id; + } + const count = await SecretSnapshot.countDocuments({ workspace: workspaceId, environment, - folderId: folderId || "root" + folderId }); return res.status(200).send({ @@ -215,7 +235,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons const { params: { workspaceId }, - body: { folderId, environment, version } + body: { directory, environment, version } } = await validateRequest(RollbackWorkspaceSecretSnapshotV1, req); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); @@ -224,6 +244,16 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons ProjectPermissionSub.SecretRollback ); + let folderId = "root"; + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + if (folders) { + const folder = getFolderByPath(folders?.nodes, directory); + if (!folder) throw BadRequestError({ message: "Invalid folder id" }); + folderId = folder.id; + } + // validate secret snapshot const secretSnapshot = await SecretSnapshot.findOne({ workspace: workspaceId, diff --git a/backend/src/ee/models/auditLog/enums.ts b/backend/src/ee/models/auditLog/enums.ts index dda49c1cd..c39b26502 100644 --- a/backend/src/ee/models/auditLog/enums.ts +++ b/backend/src/ee/models/auditLog/enums.ts @@ -1,47 +1,50 @@ export enum ActorType { - USER = "user", - SERVICE = "service" + USER = "user", + SERVICE = "service" } export enum UserAgentType { - WEB = "web", - CLI = "cli", - K8_OPERATOR = "k8-operator", - OTHER = "other" + WEB = "web", + CLI = "cli", + K8_OPERATOR = "k8-operator", + OTHER = "other" } export enum EventType { - GET_SECRETS = "get-secrets", - GET_SECRET = "get-secret", - REVEAL_SECRET = "reveal-secret", - CREATE_SECRET = "create-secret", - UPDATE_SECRET = "update-secret", - DELETE_SECRET = "delete-secret", - GET_WORKSPACE_KEY = "get-workspace-key", - AUTHORIZE_INTEGRATION = "authorize-integration", - UNAUTHORIZE_INTEGRATION = "unauthorize-integration", - CREATE_INTEGRATION = "create-integration", - DELETE_INTEGRATION = "delete-integration", - ADD_TRUSTED_IP = "add-trusted-ip", - UPDATE_TRUSTED_IP = "update-trusted-ip", - DELETE_TRUSTED_IP = "delete-trusted-ip", - CREATE_SERVICE_TOKEN = "create-service-token", - DELETE_SERVICE_TOKEN = "delete-service-token", - CREATE_ENVIRONMENT = "create-environment", - UPDATE_ENVIRONMENT = "update-environment", - DELETE_ENVIRONMENT = "delete-environment", - ADD_WORKSPACE_MEMBER = "add-workspace-member", - REMOVE_WORKSPACE_MEMBER = "remove-workspace-member", - CREATE_FOLDER = "create-folder", - UPDATE_FOLDER = "update-folder", - DELETE_FOLDER = "delete-folder", - CREATE_WEBHOOK = "create-webhook", - UPDATE_WEBHOOK_STATUS = "update-webhook-status", - DELETE_WEBHOOK = "delete-webhook", - GET_SECRET_IMPORTS = "get-secret-imports", - CREATE_SECRET_IMPORT = "create-secret-import", - UPDATE_SECRET_IMPORT = "update-secret-import", - DELETE_SECRET_IMPORT = "delete-secret-import", - UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", - UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions" -} \ No newline at end of file + GET_SECRETS = "get-secrets", + GET_SECRET = "get-secret", + REVEAL_SECRET = "reveal-secret", + CREATE_SECRET = "create-secret", + CREATE_SECRETS = "create-secrets", + UPDATE_SECRET = "update-secret", + UPDATE_SECRETS = "update-secrets", + DELETE_SECRET = "delete-secret", + DELETE_SECRETS = "delete-secrets", + GET_WORKSPACE_KEY = "get-workspace-key", + AUTHORIZE_INTEGRATION = "authorize-integration", + UNAUTHORIZE_INTEGRATION = "unauthorize-integration", + CREATE_INTEGRATION = "create-integration", + DELETE_INTEGRATION = "delete-integration", + ADD_TRUSTED_IP = "add-trusted-ip", + UPDATE_TRUSTED_IP = "update-trusted-ip", + DELETE_TRUSTED_IP = "delete-trusted-ip", + CREATE_SERVICE_TOKEN = "create-service-token", + DELETE_SERVICE_TOKEN = "delete-service-token", + CREATE_ENVIRONMENT = "create-environment", + UPDATE_ENVIRONMENT = "update-environment", + DELETE_ENVIRONMENT = "delete-environment", + ADD_WORKSPACE_MEMBER = "add-workspace-member", + REMOVE_WORKSPACE_MEMBER = "remove-workspace-member", + CREATE_FOLDER = "create-folder", + UPDATE_FOLDER = "update-folder", + DELETE_FOLDER = "delete-folder", + CREATE_WEBHOOK = "create-webhook", + UPDATE_WEBHOOK_STATUS = "update-webhook-status", + DELETE_WEBHOOK = "delete-webhook", + GET_SECRET_IMPORTS = "get-secret-imports", + CREATE_SECRET_IMPORT = "create-secret-import", + UPDATE_SECRET_IMPORT = "update-secret-import", + DELETE_SECRET_IMPORT = "delete-secret-import", + UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", + UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions" +} diff --git a/backend/src/ee/models/auditLog/types.ts b/backend/src/ee/models/auditLog/types.ts index 71507212e..c03b109db 100644 --- a/backend/src/ee/models/auditLog/types.ts +++ b/backend/src/ee/models/auditLog/types.ts @@ -1,403 +1,428 @@ -import { - ActorType, - EventType -} from "./enums"; +import { ActorType, EventType } from "./enums"; interface UserActorMetadata { - userId: string; - email: string; + userId: string; + email: string; } interface ServiceActorMetadata { - serviceId: string; - name: string; + serviceId: string; + name: string; } export interface UserActor { - type: ActorType.USER; - metadata: UserActorMetadata; + type: ActorType.USER; + metadata: UserActorMetadata; } export interface ServiceActor { - type: ActorType.SERVICE; - metadata: ServiceActorMetadata; + type: ActorType.SERVICE; + metadata: ServiceActorMetadata; } -export type Actor = - | UserActor - | ServiceActor; +export type Actor = UserActor | ServiceActor; interface GetSecretsEvent { - type: EventType.GET_SECRETS; - metadata: { - environment: string; - secretPath: string; - numberOfSecrets: number; - }; + type: EventType.GET_SECRETS; + metadata: { + environment: string; + secretPath: string; + numberOfSecrets: number; + }; } interface GetSecretEvent { - type: EventType.GET_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - }; + type: EventType.GET_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; } interface CreateSecretEvent { - type: EventType.CREATE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - } + type: EventType.CREATE_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; +} + +interface CreateSecretBatchEvent { + type: EventType.CREATE_SECRETS; + metadata: { + environment: string; + secretPath: string; + secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; + }; } interface UpdateSecretEvent { - type: EventType.UPDATE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - } + type: EventType.UPDATE_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; +} + +interface UpdateSecretBatchEvent { + type: EventType.UPDATE_SECRETS; + metadata: { + environment: string; + secretPath: string; + secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; + }; } interface DeleteSecretEvent { - type: EventType.DELETE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - } + type: EventType.DELETE_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; +} + +interface DeleteSecretBatchEvent { + type: EventType.DELETE_SECRETS; + metadata: { + environment: string; + secretPath: string; + secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; + }; } interface GetWorkspaceKeyEvent { - type: EventType.GET_WORKSPACE_KEY, - metadata: { - keyId: string; - } + type: EventType.GET_WORKSPACE_KEY; + metadata: { + keyId: string; + }; } interface AuthorizeIntegrationEvent { - type: EventType.AUTHORIZE_INTEGRATION; - metadata: { - integration: string; - } + type: EventType.AUTHORIZE_INTEGRATION; + metadata: { + integration: string; + }; } interface UnauthorizeIntegrationEvent { - type: EventType.UNAUTHORIZE_INTEGRATION; - metadata: { - integration: string; - } + type: EventType.UNAUTHORIZE_INTEGRATION; + metadata: { + integration: string; + }; } interface CreateIntegrationEvent { - type: EventType.CREATE_INTEGRATION; - metadata: { - integrationId: string; - integration: string; // TODO: fix type - environment: string; - secretPath: string; - url?: string; - app?: string; - appId?: string; - targetEnvironment?: string; - targetEnvironmentId?: string; - targetService?: string; - targetServiceId?: string; - path?: string; - region?: string; - } + type: EventType.CREATE_INTEGRATION; + metadata: { + integrationId: string; + integration: string; // TODO: fix type + environment: string; + secretPath: string; + url?: string; + app?: string; + appId?: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + path?: string; + region?: string; + }; } interface DeleteIntegrationEvent { - type: EventType.DELETE_INTEGRATION; - metadata: { - integrationId: string; - integration: string; // TODO: fix type - environment: string; - secretPath: string; - url?: string; - app?: string; - appId?: string; - targetEnvironment?: string; - targetEnvironmentId?: string; - targetService?: string; - targetServiceId?: string; - path?: string; - region?: string; - } + type: EventType.DELETE_INTEGRATION; + metadata: { + integrationId: string; + integration: string; // TODO: fix type + environment: string; + secretPath: string; + url?: string; + app?: string; + appId?: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + path?: string; + region?: string; + }; } interface AddTrustedIPEvent { - type: EventType.ADD_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - } + type: EventType.ADD_TRUSTED_IP; + metadata: { + trustedIpId: string; + ipAddress: string; + prefix?: number; + }; } interface UpdateTrustedIPEvent { - type: EventType.UPDATE_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - } + type: EventType.UPDATE_TRUSTED_IP; + metadata: { + trustedIpId: string; + ipAddress: string; + prefix?: number; + }; } interface DeleteTrustedIPEvent { - type: EventType.DELETE_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - } + type: EventType.DELETE_TRUSTED_IP; + metadata: { + trustedIpId: string; + ipAddress: string; + prefix?: number; + }; } interface CreateServiceTokenEvent { - type: EventType.CREATE_SERVICE_TOKEN; - metadata: { - name: string; - scopes: Array<{ - environment: string; - secretPath: string; - }>; - } + type: EventType.CREATE_SERVICE_TOKEN; + metadata: { + name: string; + scopes: Array<{ + environment: string; + secretPath: string; + }>; + }; } interface DeleteServiceTokenEvent { - type: EventType.DELETE_SERVICE_TOKEN; - metadata: { - name: string; - scopes: Array<{ - environment: string; - secretPath: string; - }>; - } + type: EventType.DELETE_SERVICE_TOKEN; + metadata: { + name: string; + scopes: Array<{ + environment: string; + secretPath: string; + }>; + }; } interface CreateEnvironmentEvent { - type: EventType.CREATE_ENVIRONMENT; - metadata: { - name: string; - slug: string; - } + type: EventType.CREATE_ENVIRONMENT; + metadata: { + name: string; + slug: string; + }; } interface UpdateEnvironmentEvent { - type: EventType.UPDATE_ENVIRONMENT; - metadata: { - oldName: string; - newName: string; - oldSlug: string; - newSlug: string; - } + type: EventType.UPDATE_ENVIRONMENT; + metadata: { + oldName: string; + newName: string; + oldSlug: string; + newSlug: string; + }; } interface DeleteEnvironmentEvent { - type: EventType.DELETE_ENVIRONMENT; - metadata: { - name: string; - slug: string; - } + type: EventType.DELETE_ENVIRONMENT; + metadata: { + name: string; + slug: string; + }; } interface AddWorkspaceMemberEvent { - type: EventType.ADD_WORKSPACE_MEMBER; - metadata: { - userId: string; - email: string; - } + type: EventType.ADD_WORKSPACE_MEMBER; + metadata: { + userId: string; + email: string; + }; } interface RemoveWorkspaceMemberEvent { - type: EventType.REMOVE_WORKSPACE_MEMBER; - metadata: { - userId: string; - email: string; - } + type: EventType.REMOVE_WORKSPACE_MEMBER; + metadata: { + userId: string; + email: string; + }; } interface CreateFolderEvent { - type: EventType.CREATE_FOLDER; - metadata: { - environment: string; - folderId: string; - folderName: string; - folderPath: string; - } + type: EventType.CREATE_FOLDER; + metadata: { + environment: string; + folderId: string; + folderName: string; + folderPath: string; + }; } interface UpdateFolderEvent { - type: EventType.UPDATE_FOLDER; - metadata: { - environment: string; - folderId: string; - oldFolderName: string; - newFolderName: string; - folderPath: string; - } + type: EventType.UPDATE_FOLDER; + metadata: { + environment: string; + folderId: string; + oldFolderName: string; + newFolderName: string; + folderPath: string; + }; } interface DeleteFolderEvent { - type: EventType.DELETE_FOLDER; - metadata: { - environment: string; - folderId: string; - folderName: string; - folderPath: string; - } + type: EventType.DELETE_FOLDER; + metadata: { + environment: string; + folderId: string; + folderName: string; + folderPath: string; + }; } interface CreateWebhookEvent { - type: EventType.CREATE_WEBHOOK, - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - } + type: EventType.CREATE_WEBHOOK; + metadata: { + webhookId: string; + environment: string; + secretPath: string; + webhookUrl: string; + isDisabled: boolean; + }; } interface UpdateWebhookStatusEvent { - type: EventType.UPDATE_WEBHOOK_STATUS, - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - } + type: EventType.UPDATE_WEBHOOK_STATUS; + metadata: { + webhookId: string; + environment: string; + secretPath: string; + webhookUrl: string; + isDisabled: boolean; + }; } interface DeleteWebhookEvent { - type: EventType.DELETE_WEBHOOK, - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - } + type: EventType.DELETE_WEBHOOK; + metadata: { + webhookId: string; + environment: string; + secretPath: string; + webhookUrl: string; + isDisabled: boolean; + }; } interface GetSecretImportsEvent { - type: EventType.GET_SECRET_IMPORTS, - metadata: { - environment: string; - secretImportId: string; - folderId: string; - numberOfImports: number; - } + type: EventType.GET_SECRET_IMPORTS; + metadata: { + environment: string; + secretImportId: string; + folderId: string; + numberOfImports: number; + }; } interface CreateSecretImportEvent { - type: EventType.CREATE_SECRET_IMPORT, - metadata: { - secretImportId: string; - folderId: string; - importFromEnvironment: string; - importFromSecretPath: string; - importToEnvironment: string; - importToSecretPath: string; - } + type: EventType.CREATE_SECRET_IMPORT; + metadata: { + secretImportId: string; + folderId: string; + importFromEnvironment: string; + importFromSecretPath: string; + importToEnvironment: string; + importToSecretPath: string; + }; } interface UpdateSecretImportEvent { - type: EventType.UPDATE_SECRET_IMPORT, - metadata: { - secretImportId: string; - folderId: string; - importToEnvironment: string; - importToSecretPath: string; - orderBefore: { - environment: string; - secretPath: string; - }[], - orderAfter: { - environment: string; - secretPath: string; - }[] - } + type: EventType.UPDATE_SECRET_IMPORT; + metadata: { + secretImportId: string; + folderId: string; + importToEnvironment: string; + importToSecretPath: string; + orderBefore: { + environment: string; + secretPath: string; + }[]; + orderAfter: { + environment: string; + secretPath: string; + }[]; + }; } interface DeleteSecretImportEvent { - type: EventType.DELETE_SECRET_IMPORT, - metadata: { - secretImportId: string; - folderId: string; - importFromEnvironment: string; - importFromSecretPath: string; - importToEnvironment: string; - importToSecretPath: string; - } + type: EventType.DELETE_SECRET_IMPORT; + metadata: { + secretImportId: string; + folderId: string; + importFromEnvironment: string; + importFromSecretPath: string; + importToEnvironment: string; + importToSecretPath: string; + }; } interface UpdateUserRole { - type: EventType.UPDATE_USER_WORKSPACE_ROLE, - metadata: { - userId: string; - email: string; - oldRole: string; - newRole: string; - } + type: EventType.UPDATE_USER_WORKSPACE_ROLE; + metadata: { + userId: string; + email: string; + oldRole: string; + newRole: string; + }; } interface UpdateUserDeniedPermissions { - type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS, - metadata: { - userId: string; - email: string; - deniedPermissions: { - environmentSlug: string; - ability: string; - }[] - } + type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS; + metadata: { + userId: string; + email: string; + deniedPermissions: { + environmentSlug: string; + ability: string; + }[]; + }; } -export type Event = - | GetSecretsEvent - | GetSecretEvent - | CreateSecretEvent - | UpdateSecretEvent - | DeleteSecretEvent - | GetWorkspaceKeyEvent - | AuthorizeIntegrationEvent - | UnauthorizeIntegrationEvent - | CreateIntegrationEvent - | DeleteIntegrationEvent - | AddTrustedIPEvent - | UpdateTrustedIPEvent - | DeleteTrustedIPEvent - | CreateServiceTokenEvent - | DeleteServiceTokenEvent - | CreateEnvironmentEvent - | UpdateEnvironmentEvent - | DeleteEnvironmentEvent - | AddWorkspaceMemberEvent - | RemoveWorkspaceMemberEvent - | CreateFolderEvent - | UpdateFolderEvent - | DeleteFolderEvent - | CreateWebhookEvent - | UpdateWebhookStatusEvent - | DeleteWebhookEvent - | GetSecretImportsEvent - | CreateSecretImportEvent - | UpdateSecretImportEvent - | DeleteSecretImportEvent - | UpdateUserRole - | UpdateUserDeniedPermissions; \ No newline at end of file +export type Event = + | GetSecretsEvent + | GetSecretEvent + | CreateSecretEvent + | CreateSecretBatchEvent + | UpdateSecretEvent + | UpdateSecretBatchEvent + | DeleteSecretEvent + | DeleteSecretBatchEvent + | GetWorkspaceKeyEvent + | AuthorizeIntegrationEvent + | UnauthorizeIntegrationEvent + | CreateIntegrationEvent + | DeleteIntegrationEvent + | AddTrustedIPEvent + | UpdateTrustedIPEvent + | DeleteTrustedIPEvent + | CreateServiceTokenEvent + | DeleteServiceTokenEvent + | CreateEnvironmentEvent + | UpdateEnvironmentEvent + | DeleteEnvironmentEvent + | AddWorkspaceMemberEvent + | RemoveWorkspaceMemberEvent + | CreateFolderEvent + | UpdateFolderEvent + | DeleteFolderEvent + | CreateWebhookEvent + | UpdateWebhookStatusEvent + | DeleteWebhookEvent + | GetSecretImportsEvent + | CreateSecretImportEvent + | UpdateSecretImportEvent + | DeleteSecretImportEvent + | UpdateUserRole + | UpdateUserDeniedPermissions; diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 84174ac87..11ffa79ab 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -4,7 +4,7 @@ import { ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8, SECRET_PERSONAL, - SECRET_SHARED, + SECRET_SHARED } from "../../variables"; export interface ISecretVersion { @@ -23,6 +23,7 @@ export interface ISecretVersion { secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; + skipMultilineEncoding?: boolean; algorithm: "aes-256-gcm"; keyEncoding: "utf8" | "base64"; createdAt: string; @@ -36,95 +37,96 @@ const secretVersionSchema = new Schema( // could be deleted type: Schema.Types.ObjectId, ref: "Secret", - required: true, + required: true }, version: { type: Number, default: 1, - required: true, + required: true }, workspace: { type: Schema.Types.ObjectId, ref: "Workspace", - required: true, + required: true }, type: { type: String, enum: [SECRET_SHARED, SECRET_PERSONAL], - required: true, + required: true }, user: { // user associated with the personal secret type: Schema.Types.ObjectId, - ref: "User", + ref: "User" }, environment: { type: String, - required: true, + required: true }, isDeleted: { // consider removing field type: Boolean, default: false, - required: true, + required: true }, secretBlindIndex: { type: String, - select: false, + select: false }, secretKeyCiphertext: { type: String, - required: true, + required: true }, secretKeyIV: { type: String, // symmetric - required: true, + required: true }, secretKeyTag: { type: String, // symmetric - required: true, + required: true }, secretValueCiphertext: { type: String, - required: true, + required: true }, secretValueIV: { type: String, // symmetric - required: true, + required: true }, secretValueTag: { type: String, // symmetric - required: true, + required: true + }, + skipMultilineEncoding: { + type: Boolean, + required: false }, algorithm: { // the encryption algorithm used type: String, enum: [ALGORITHM_AES_256_GCM], required: true, - default: ALGORITHM_AES_256_GCM, + default: ALGORITHM_AES_256_GCM }, keyEncoding: { type: String, enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64], required: true, - default: ENCODING_SCHEME_UTF8, + default: ENCODING_SCHEME_UTF8 }, folder: { type: String, - required: true, + required: true }, tags: { ref: "Tag", type: [Schema.Types.ObjectId], - default: [], + default: [] } }, { - timestamps: true, + timestamps: true } ); -export const SecretVersion = model( - "SecretVersion", - secretVersionSchema -); \ No newline at end of file +export const SecretVersion = model("SecretVersion", secretVersionSchema); diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 5b4c8b465..ec40c9016 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -1,9 +1,12 @@ import { Types } from "mongoose"; import { + CreateSecretBatchParams, CreateSecretParams, + DeleteSecretBatchParams, DeleteSecretParams, GetSecretParams, GetSecretsParams, + UpdateSecretBatchParams, UpdateSecretParams } from "../interfaces/services/SecretService"; import { @@ -71,6 +74,8 @@ export function containsGlobPatterns(secretPath: string) { return globChars.some((char) => normalizedPath.includes(char)); } +const ERR_FOLDER_NOT_FOUND = BadRequestError({ message: "Folder not found" }); + /** * Returns an object containing secret [secret] but with its value, key, comment decrypted. * @@ -330,7 +335,8 @@ export const createSecretHelper = async ({ secretCommentIV, secretCommentTag, secretPath = "/", - metadata + metadata, + skipMultilineEncoding }: CreateSecretParams) => { const secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, @@ -394,6 +400,7 @@ export const createSecretHelper = async ({ secretCommentCiphertext, secretCommentIV, secretCommentTag, + skipMultilineEncoding, folder: folderId, algorithm: ALGORITHM_AES_256_GCM, keyEncoding: ENCODING_SCHEME_UTF8, @@ -416,6 +423,7 @@ export const createSecretHelper = async ({ secretValueCiphertext, secretValueIV, secretValueTag, + skipMultilineEncoding, algorithm: ALGORITHM_AES_256_GCM, keyEncoding: ENCODING_SCHEME_UTF8 }); @@ -740,19 +748,50 @@ export const updateSecretHelper = async ({ environment, type, authData, + newSecretName, + secretKeyTag, + secretKeyCiphertext, + secretKeyIV, secretValueCiphertext, secretValueIV, secretValueTag, - secretPath + secretPath, + tags, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + skipMultilineEncoding }: UpdateSecretParams) => { - const secretBlindIndex = await generateSecretBlindIndexHelper({ - secretName, + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ workspaceId: new Types.ObjectId(workspaceId) }); + const secretBlindIndex = await generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }); + let secret: ISecret | null = null; const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); + let newSecretNameBlindIndex = undefined; + if (newSecretName) { + newSecretNameBlindIndex = await generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }); + const doesSecretAlreadyExist = await Secret.exists({ + secretBlindIndex: newSecretNameBlindIndex, + workspace: new Types.ObjectId(workspaceId), + environment, + folder: folderId, + type + }); + if (!doesSecretAlreadyExist) + throw BadRequestError({ message: "Secret with the provided name already exist" }); + } + if (type === SECRET_SHARED) { // case: update shared secret secret = await Secret.findOneAndUpdate( @@ -767,6 +806,15 @@ export const updateSecretHelper = async ({ secretValueCiphertext, secretValueIV, secretValueTag, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretBlindIndex: newSecretNameBlindIndex, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + tags, $inc: { version: 1 } }, { @@ -782,6 +830,9 @@ export const updateSecretHelper = async ({ workspace: new Types.ObjectId(workspaceId), environment, type, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, folder: folderId, ...getAuthDataPayloadUserObj(authData) }, @@ -789,6 +840,9 @@ export const updateSecretHelper = async ({ secretValueCiphertext, secretValueIV, secretValueTag, + tags, + skipMultilineEncoding, + secretBlindIndex: newSecretNameBlindIndex, $inc: { version: 1 } }, { @@ -805,16 +859,18 @@ export const updateSecretHelper = async ({ workspace: secret.workspace, folder: folderId, type, + tags, ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), environment: secret.environment, isDeleted: false, - secretBlindIndex, + secretBlindIndex: newSecretName ? newSecretNameBlindIndex : secretBlindIndex, secretKeyCiphertext: secret.secretKeyCiphertext, secretKeyIV: secret.secretKeyIV, secretKeyTag: secret.secretKeyTag, secretValueCiphertext, secretValueIV, secretValueTag, + skipMultilineEncoding, algorithm: ALGORITHM_AES_256_GCM, keyEncoding: ENCODING_SCHEME_UTF8 }); @@ -1185,3 +1241,550 @@ export const expandSecrets = async ( return secrets; }; + +export const createSecretBatchHelper = async ({ + secrets, + workspaceId, + authData, + secretPath, + environment +}: CreateSecretBatchParams) => { + let folderId = "root"; + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }); + + if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND; + if (folders) { + const folder = getFolderByPath(folders.nodes, secretPath); + if (!folder) throw ERR_FOLDER_NOT_FOUND; + folderId = folder.id; + } + + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + secrets.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[secrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = secrets[i].secretName; + return prev; + }, {}) + ); + + const exists = await Secret.exists({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type, + ...getAuthDataPayloadUserObj(authData) + })) + ) + .exec(); + + if (exists) + throw BadRequestError({ + message: "Failed to create secret that already exists" + }); + + // create secret + const newlyCreatedSecrets: ISecret[] = await Secret.insertMany( + secrets.map( + ({ + type, + secretName, + secretKeyIV, + metadata, + secretKeyTag, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding + }) => ({ + version: 1, + workspace: new Types.ObjectId(workspaceId), + environment, + type, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + folder: folderId, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, + metadata, + skipMultilineEncoding, + secretBlindIndex: secretBlindIndexes[secretName], + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + }) + ) + ); + + await EESecretService.addSecretVersions({ + secretVersions: newlyCreatedSecrets.map( + (secret) => + new SecretVersion({ + secret: secret._id, + version: secret.version, + workspace: secret.workspace, + type: secret.type, + folder: folderId, + skipMultilineEncoding: secret?.skipMultilineEncoding, + ...(secret.type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), + environment: secret.environment, + isDeleted: false, + secretBlindIndex: secret.secretBlindIndex, + secretKeyCiphertext: secret.secretKeyCiphertext, + secretKeyIV: secret.secretKeyIV, + secretKeyTag: secret.secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }) + ) + }); + + // (EE) create (audit) log + const action = await EELogService.createAction({ + name: ACTION_ADD_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: newlyCreatedSecrets.map((n) => n._id) + }); + + action && + (await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.userAgentType, + ipAddress: authData.ipAddress + })); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.CREATE_SECRETS, + metadata: { + environment, + secretPath, + secrets: newlyCreatedSecrets.map(({ secretBlindIndex, version, _id }) => ({ + secretId: _id.toString(), + secretKey: secretBlindIndexToKey[secretBlindIndex || ""], + secretVersion: version + })) + } + }, + { + workspaceId + } + ); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId, + environment, + folderId + }); + + const postHogClient = await TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: "secrets added", + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: 1, + environment, + workspaceId, + folderId, + channel: authData.userAgentType, + userAgent: authData.userAgent + } + }); + } + + return newlyCreatedSecrets; +}; + +export const updateSecretBatchHelper = async ({ + workspaceId, + environment, + authData, + secretPath, + secrets +}: UpdateSecretBatchParams) => { + let folderId = "root"; + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }); + + if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND; + if (folders) { + const folder = getFolderByPath(folders.nodes, secretPath); + if (!folder) throw ERR_FOLDER_NOT_FOUND; + folderId = folder.id; + } + + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + secrets.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[secrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = secrets[i].secretName; + return prev; + }, {}) + ); + + const secretsToBeUpdated = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .select("+secretBlindIndex") + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + })) + ) + .lean(); + + if (secretsToBeUpdated.length !== secrets.length) + throw BadRequestError({ message: "Some secrets not found" }); + + await Secret.bulkWrite( + secrets.map( + ({ + type, + secretName, + tags, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding + }) => ({ + updateOne: { + filter: { + workspace: new Types.ObjectId(workspaceId), + environment, + folder: folderId, + secretBlindIndex: secretBlindIndexes[secretName], + type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + }, + update: { + $inc: { + version: 1 + }, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, + tags, + skipMultilineEncoding + } + } + }) + ) + ); + + const secretsGroupedByBlindIndex = secretsToBeUpdated.reduce>( + (prev, curr) => { + if (curr.secretBlindIndex) prev[curr.secretBlindIndex] = curr; + return prev; + }, + {} + ); + + await EESecretService.addSecretVersions({ + secretVersions: secrets.map((secret) => { + const { + _id, + version, + workspace, + type, + secretBlindIndex, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + skipMultilineEncoding + } = secretsGroupedByBlindIndex[secretBlindIndexes[secret.secretName]]; + + return new SecretVersion({ + secret: _id, + version: version + 1, + workspace: workspace, + type, + folder: folderId, + ...(secret.type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), + environment, + isDeleted: false, + secretBlindIndex: secretBlindIndex, + secretKeyCiphertext: secretKeyCiphertext, + secretKeyIV: secretKeyIV, + secretKeyTag: secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, + skipMultilineEncoding + }); + }) + }); + + // (EE) create (audit) log + const action = await EELogService.createAction({ + name: ACTION_UPDATE_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: secretsToBeUpdated.map(({ _id }) => _id) + }); + + action && + (await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.userAgentType, + ipAddress: authData.ipAddress + })); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.UPDATE_SECRETS, + metadata: { + environment, + secretPath, + secrets: secretsToBeUpdated.map(({ _id, version, secretBlindIndex }) => ({ + secretId: _id.toString(), + secretKey: secretBlindIndexToKey[secretBlindIndex || ""], + secretVersion: version + 1 + })) + } + }, + { + workspaceId + } + ); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId, + environment, + folderId + }); + + const postHogClient = await TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: "secrets modified", + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: 1, + environment, + workspaceId, + folderId, + channel: authData.userAgentType, + userAgent: authData.userAgent + } + }); + } + + return; +}; + +export const deleteSecretBatchHelper = async ({ + workspaceId, + environment, + authData, + secretPath = "/", + secrets +}: DeleteSecretBatchParams) => { + let folderId = "root"; + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }); + + if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND; + if (folders) { + const folder = getFolderByPath(folders.nodes, secretPath); + if (!folder) throw ERR_FOLDER_NOT_FOUND; + folderId = folder.id; + } + + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + secrets.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[secrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = secrets[i].secretName; + return prev; + }, {}) + ); + + const deletedSecrets = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type === "shared" ? { $in: ["shared", "personal"] } : type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + })) + ) + .select({ secretBlindIndexes: 1 }) + .lean() + .exec(); + + await Secret.deleteMany({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type === "shared" ? { $in: ["shared", "personal"] } : type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + })) + ) + .exec(); + + await EESecretService.markDeletedSecretVersions({ + secretIds: deletedSecrets.map((secret) => secret._id) + }); + + // (EE) create (audit) log + const action = await EELogService.createAction({ + name: ACTION_DELETE_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: deletedSecrets.map((secret) => secret._id) + }); + + action && + (await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.userAgentType, + ipAddress: authData.ipAddress + })); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.DELETE_SECRETS, + metadata: { + environment, + secretPath, + secrets: deletedSecrets.map(({ _id, version, secretBlindIndex }) => ({ + secretId: _id.toString(), + secretKey: secretBlindIndexToKey[secretBlindIndex || ""], + secretVersion: version + })) + } + }, + { + workspaceId + } + ); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId, + environment, + folderId + }); + + const postHogClient = await TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: "secrets deleted", + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: secrets.length, + environment, + workspaceId, + folderId, + channel: authData.userAgentType, + userAgent: authData.userAgent + } + }); + } + + return { + secrets: deletedSecrets + }; +}; diff --git a/backend/src/interfaces/services/SecretService/index.ts b/backend/src/interfaces/services/SecretService/index.ts index f1f89a83b..f42bbadf6 100644 --- a/backend/src/interfaces/services/SecretService/index.ts +++ b/backend/src/interfaces/services/SecretService/index.ts @@ -16,6 +16,7 @@ export interface CreateSecretParams { secretCommentCiphertext?: string; secretCommentIV?: string; secretCommentTag?: string; + skipMultilineEncoding?: boolean; secretPath: string; metadata?: { source?: string; @@ -42,6 +43,10 @@ export interface GetSecretParams { export interface UpdateSecretParams { secretName: string; + newSecretName?: string; + secretKeyCiphertext?: string; + secretKeyIV?: string; + secretKeyTag?: string; workspaceId: Types.ObjectId; environment: string; type: "shared" | "personal"; @@ -50,6 +55,11 @@ export interface UpdateSecretParams { secretValueIV: string; secretValueTag: string; secretPath: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + tags?: string[]; } export interface DeleteSecretParams { @@ -60,3 +70,57 @@ export interface DeleteSecretParams { authData: AuthData; secretPath: string; } + +export interface CreateSecretBatchParams { + workspaceId: Types.ObjectId; + environment: string; + authData: AuthData; + secretPath: string; + secrets: Array<{ + secretName: string; + type: "shared" | "personal"; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + metadata?: { + source?: string; + }; + }>; +} + +export interface UpdateSecretBatchParams { + workspaceId: Types.ObjectId; + environment: string; + authData: AuthData; + secretPath: string; + secrets: Array<{ + secretName: string; + type: "shared" | "personal"; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + tags?: string[]; + }>; +} + +export interface DeleteSecretBatchParams { + workspaceId: Types.ObjectId; + environment: string; + authData: AuthData; + secretPath: string; + secrets: Array<{ + secretName: string; + type: "shared" | "personal"; + }>; +} diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index b7d7c266d..8592e518a 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -4,7 +4,7 @@ import { ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8, SECRET_PERSONAL, - SECRET_SHARED, + SECRET_SHARED } from "../variables"; export interface ISecret { @@ -12,7 +12,7 @@ export interface ISecret { version: number; workspace: Types.ObjectId; type: string; - user: Types.ObjectId; + user?: Types.ObjectId; environment: string; secretBlindIndex?: string; secretKeyCiphertext: string; @@ -27,13 +27,14 @@ export interface ISecret { secretCommentIV?: string; secretCommentTag?: string; secretCommentHash?: string; + skipMultilineEncoding?: boolean; algorithm: "aes-256-gcm"; keyEncoding: "utf8" | "base64"; tags?: string[]; folder?: string; metadata?: { [key: string]: string; - } + }; } const secretSchema = new Schema( @@ -41,108 +42,112 @@ const secretSchema = new Schema( version: { type: Number, required: true, - default: 1, + default: 1 }, workspace: { type: Schema.Types.ObjectId, ref: "Workspace", - required: true, + required: true }, type: { type: String, enum: [SECRET_SHARED, SECRET_PERSONAL], - required: true, + required: true }, user: { // user associated with the personal secret type: Schema.Types.ObjectId, - ref: "User", + ref: "User" }, tags: { ref: "Tag", type: [Schema.Types.ObjectId], - default: [], + default: [] }, environment: { type: String, - required: true, + required: true }, secretBlindIndex: { type: String, - select: false, + select: false }, secretKeyCiphertext: { type: String, - required: true, + required: true }, secretKeyIV: { type: String, // symmetric - required: true, + required: true }, secretKeyTag: { type: String, // symmetric - required: true, + required: true }, secretKeyHash: { - type: String, + type: String }, secretValueCiphertext: { type: String, - required: true, + required: true }, secretValueIV: { type: String, // symmetric - required: true, + required: true }, secretValueTag: { type: String, // symmetric - required: true, + required: true }, secretValueHash: { - type: String, + type: String }, secretCommentCiphertext: { type: String, - required: false, + required: false }, secretCommentIV: { type: String, // symmetric - required: false, + required: false }, secretCommentTag: { type: String, // symmetric - required: false, + required: false }, secretCommentHash: { type: String, - required: false, + required: false + }, + skipMultilineEncoding: { + type: Boolean, + required: false }, algorithm: { // the encryption algorithm used type: String, enum: [ALGORITHM_AES_256_GCM], required: true, - default: ALGORITHM_AES_256_GCM, + default: ALGORITHM_AES_256_GCM }, keyEncoding: { type: String, enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64], required: true, - default: ENCODING_SCHEME_UTF8, + default: ENCODING_SCHEME_UTF8 }, folder: { type: String, - default: "root", + default: "root" }, metadata: { type: Schema.Types.Mixed } }, { - timestamps: true, + timestamps: true } ); secretSchema.index({ tags: 1 }, { background: true }); -export const Secret = model("Secret", secretSchema); \ No newline at end of file +export const Secret = model("Secret", secretSchema); diff --git a/backend/src/routes/v1/secretsFolder.ts b/backend/src/routes/v1/secretsFolder.ts index 00ca2acea..697c4d3c9 100644 --- a/backend/src/routes/v1/secretsFolder.ts +++ b/backend/src/routes/v1/secretsFolder.ts @@ -18,7 +18,7 @@ router.post( ); router.patch( - "/:folderId", + "/:folderName", requireAuth({ acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] }), @@ -26,7 +26,7 @@ router.patch( ); router.delete( - "/:folderId", + "/:folderName", requireAuth({ acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] }), diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index 1765c2e33..0c4aff6a0 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -1,14 +1,8 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - requireBlindIndicesEnabled, - requireE2EEOff -} from "../../middleware"; +import { requireAuth, requireBlindIndicesEnabled, requireE2EEOff } from "../../middleware"; import { secretsController } from "../../controllers/v3"; -import { - AuthMode -} from "../../variables"; +import { AuthMode } from "../../variables"; router.get( "/raw", @@ -85,6 +79,40 @@ router.get( secretsController.getSecrets ); +// akhilmhdh: dont put batch router below the individual operation as those have arbitory name as params +router.post( + "/batch", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + }), + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + secretsController.createSecretByNameBatch +); + +router.patch( + "/batch", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + }), + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + secretsController.updateSecretByNameBatch +); + +router.delete( + "/batch", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + }), + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + secretsController.deleteSecretByNameBatch +); + router.post( "/:secretName", requireAuth({ diff --git a/backend/src/services/SecretImportService.ts b/backend/src/services/SecretImportService.ts index d07c841f5..ffe4df9c6 100644 --- a/backend/src/services/SecretImportService.ts +++ b/backend/src/services/SecretImportService.ts @@ -108,14 +108,6 @@ export const getAllImportedSecrets = async ( type: "shared" } }, - { - $lookup: { - from: "tags", // note this is the name of the collection in the database, not the Mongoose model name - localField: "tags", - foreignField: "_id", - as: "tags" - } - }, { $group: { _id: { diff --git a/backend/src/services/SecretService.ts b/backend/src/services/SecretService.ts index ef372bddc..109fe1507 100644 --- a/backend/src/services/SecretService.ts +++ b/backend/src/services/SecretService.ts @@ -1,21 +1,27 @@ import { Types } from "mongoose"; import { - CreateSecretParams, - DeleteSecretParams, - GetSecretParams, - GetSecretsParams, - UpdateSecretParams, + CreateSecretBatchParams, + CreateSecretParams, + DeleteSecretBatchParams, + DeleteSecretParams, + GetSecretParams, + GetSecretsParams, + UpdateSecretBatchParams, + UpdateSecretParams } from "../interfaces/services/SecretService"; -import { - createSecretBlindIndexDataHelper, - createSecretHelper, - deleteSecretHelper, - generateSecretBlindIndexHelper, - generateSecretBlindIndexWithSaltHelper, - getSecretBlindIndexSaltHelper, - getSecretHelper, - getSecretsHelper, - updateSecretHelper, +import { + createSecretBatchHelper, + createSecretBlindIndexDataHelper, + createSecretHelper, + deleteSecretBatchHelper, + deleteSecretHelper, + generateSecretBlindIndexHelper, + generateSecretBlindIndexWithSaltHelper, + getSecretBlindIndexSaltHelper, + getSecretHelper, + getSecretsHelper, + updateSecretBatchHelper, + updateSecretHelper } from "../helpers/secrets"; class SecretService { @@ -26,13 +32,9 @@ class SecretService { * @param {Buffer} obj.salt - 16-byte random salt * @param {Types.ObjectId} obj.workspaceId */ - static async createSecretBlindIndexData({ - workspaceId, - }: { - workspaceId: Types.ObjectId; - }) { + static async createSecretBlindIndexData({ workspaceId }: { workspaceId: Types.ObjectId }) { return await createSecretBlindIndexDataHelper({ - workspaceId, + workspaceId }); } @@ -42,13 +44,9 @@ class SecretService { * @param {Types.ObjectId} obj.workspaceId - id of workspace to get salt for * @returns */ - static async getSecretBlindIndexSalt({ - workspaceId, - }: { - workspaceId: Types.ObjectId; - }) { + static async getSecretBlindIndexSalt({ workspaceId }: { workspaceId: Types.ObjectId }) { return await getSecretBlindIndexSaltHelper({ - workspaceId, + workspaceId }); } @@ -61,14 +59,14 @@ class SecretService { */ static async generateSecretBlindIndexWithSalt({ secretName, - salt, + salt }: { secretName: string; salt: string; }) { return await generateSecretBlindIndexWithSaltHelper({ secretName, - salt, + salt }); } @@ -81,14 +79,14 @@ class SecretService { */ static async generateSecretBlindIndex({ secretName, - workspaceId, + workspaceId }: { secretName: string; workspaceId: Types.ObjectId; }) { return await generateSecretBlindIndexHelper({ secretName, - workspaceId, + workspaceId }); } @@ -163,6 +161,18 @@ class SecretService { static async deleteSecret(deleteSecretParams: DeleteSecretParams) { return await deleteSecretHelper(deleteSecretParams); } + + static async createSecretBatch(createSecretParams: CreateSecretBatchParams) { + return await createSecretBatchHelper(createSecretParams); + } + + static async updateSecretBatch(updateSecretParams: UpdateSecretBatchParams) { + return await updateSecretBatchHelper(updateSecretParams); + } + + static async deleteSecretBatch(deleteSecretParams: DeleteSecretBatchParams) { + return await deleteSecretBatchHelper(deleteSecretParams); + } } export default SecretService; diff --git a/backend/src/validation/folders.ts b/backend/src/validation/folders.ts index ecfadea1c..deda48b03 100644 --- a/backend/src/validation/folders.ts +++ b/backend/src/validation/folders.ts @@ -5,28 +5,30 @@ export const CreateFolderV1 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), folderName: z.string().trim(), - parentFolderId: z.string().trim().optional() + directory: z.string().trim().default("/") }) }); export const UpdateFolderV1 = z.object({ params: z.object({ - folderId: z.string().trim() + folderName: z.string().trim() }), body: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - name: z.string().trim() + name: z.string().trim(), + directory: z.string().trim().default("/") }) }); export const DeleteFolderV1 = z.object({ params: z.object({ - folderId: z.string().trim() + folderName: z.string().trim() }), body: z.object({ workspaceId: z.string().trim(), - environment: z.string().trim() + environment: z.string().trim(), + directory: z.string().trim().default("/") }) }); @@ -34,7 +36,6 @@ export const GetFoldersV1 = z.object({ query: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - parentFolderId: z.string().trim().optional(), - parentFolderPath: z.string().trim().optional() + directory: z.string().trim().default("/") }) }); diff --git a/backend/src/validation/secretImports.ts b/backend/src/validation/secretImports.ts index 92867a67b..a899a8346 100644 --- a/backend/src/validation/secretImports.ts +++ b/backend/src/validation/secretImports.ts @@ -4,7 +4,7 @@ export const CreateSecretImportV1 = z.object({ body: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - folderId: z.string().trim().default("root"), + directory: z.string().trim().default("/"), secretImport: z.object({ environment: z.string().trim(), secretPath: z.string().trim() @@ -40,7 +40,7 @@ export const GetSecretImportsV1 = z.object({ query: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - folderId: z.string().trim().default("root") + directory: z.string().trim().default("/") }) }); @@ -48,6 +48,6 @@ export const GetAllSecretsFromImportV1 = z.object({ query: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - folderId: z.string().trim().default("root") + directory: z.string().trim().default("/") }) }); diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index 18579aa27..04a6458e6 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -259,6 +259,7 @@ export const CreateSecretRawV3 = z.object({ secretPath: z.string().trim().default("/"), secretValue: z.string().trim(), secretComment: z.string().trim(), + skipMultilineEncoding: z.boolean().optional(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]) }), params: z.object({ @@ -275,6 +276,7 @@ export const UpdateSecretByNameRawV3 = z.object({ environment: z.string().trim(), secretValue: z.string().trim(), secretPath: z.string().trim().default("/"), + skipMultilineEncoding: z.boolean().optional(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).default(SECRET_SHARED) }) }); @@ -335,7 +337,8 @@ export const CreateSecretV3 = z.object({ secretCommentCiphertext: z.string().trim().optional(), secretCommentIV: z.string().trim().optional(), secretCommentTag: z.string().trim().optional(), - metadata: z.record(z.string()).optional() + metadata: z.record(z.string()).optional(), + skipMultilineEncoding: z.boolean().optional() }), params: z.object({ secretName: z.string().trim() @@ -350,7 +353,17 @@ export const UpdateSecretByNameV3 = z.object({ secretPath: z.string().trim().default("/"), secretValueCiphertext: z.string().trim(), secretValueIV: z.string().trim(), - secretValueTag: z.string().trim() + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + tags: z.string().array().optional(), + skipMultilineEncoding: z.boolean().optional(), + // to update secret name + secretName: z.string().trim().optional(), + secretKeyIV: z.string().trim().optional(), + secretKeyTag: z.string().trim().optional(), + secretKeyCiphertext: z.string().trim().optional() }), params: z.object({ secretName: z.string() @@ -368,3 +381,67 @@ export const DeleteSecretByNameV3 = z.object({ secretName: z.string() }) }); + +export const CreateSecretByNameBatchV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), + secretKeyCiphertext: z.string().trim(), + secretKeyIV: z.string().trim(), + secretKeyTag: z.string().trim(), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + metadata: z.record(z.string()).optional(), + skipMultilineEncoding: z.boolean().optional() + }) + .array() + .min(1) + }) +}); + +export const UpdateSecretByNameBatchV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + skipMultilineEncoding: z.boolean().optional(), + tags: z.string().array().optional() + }) + .array() + .min(1) + }) +}); + +export const DeleteSecretByNameBatchV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]) + }) + .array() + .min(1) + }) +}); diff --git a/backend/src/validation/workspace.ts b/backend/src/validation/workspace.ts index e0f7c156b..9d645f5d3 100644 --- a/backend/src/validation/workspace.ts +++ b/backend/src/validation/workspace.ts @@ -33,9 +33,10 @@ export const validateClientForWorkspace = async ({ }) => { const workspace = await Workspace.findById(workspaceId); - if (!workspace) throw WorkspaceNotFoundError({ - message: "Failed to find workspace" - }); + if (!workspace) + throw WorkspaceNotFoundError({ + message: "Failed to find workspace" + }); let membership; switch (authData.actor.type) { @@ -67,7 +68,7 @@ export const GetWorkspaceSecretSnapshotsV1 = z.object({ }), query: z.object({ environment: z.string().trim(), - folderId: z.string().trim().default("root"), + directory: z.string().trim().default("/"), offset: z.coerce.number(), limit: z.coerce.number() }) @@ -79,7 +80,7 @@ export const GetWorkspaceSecretSnapshotsCountV1 = z.object({ }), query: z.object({ environment: z.string().trim(), - folderId: z.string().trim().default("root") + directory: z.string().trim().default("/") }) }); @@ -89,7 +90,7 @@ export const RollbackWorkspaceSecretSnapshotV1 = z.object({ }), body: z.object({ environment: z.string().trim(), - folderId: z.string().trim().default("root"), + directory: z.string().trim().default("/"), version: z.number() }) }); diff --git a/backend/src/variables/secret.ts b/backend/src/variables/secret.ts index 74be3a1e9..b24d8a101 100644 --- a/backend/src/variables/secret.ts +++ b/backend/src/variables/secret.ts @@ -1,3 +1,3 @@ // secrets export const SECRET_SHARED = "shared"; -export const SECRET_PERSONAL = "personal"; \ No newline at end of file +export const SECRET_PERSONAL = "personal"; From fcf3f2837e32169f613dbb478116b1fd230838a2 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 22 Sep 2023 21:50:06 +0530 Subject: [PATCH 16/30] feat(dashboard-v3): updated ui components and hooks for new migrated apis and v3 apis --- .../src/components/navigation/NavHeader.tsx | 52 ++- .../tags/CreateTagModal/CreateTagModal.tsx | 266 ++++++++++++++ .../components/tags/CreateTagModal/index.tsx | 1 + .../src/components/v2/Checkbox/Checkbox.tsx | 3 +- .../v2/ContentLoader/ContentLoader.tsx | 46 +++ .../src/components/v2/ContentLoader/index.tsx | 1 + .../src/components/v2/Dropdown/Dropdown.tsx | 70 +++- frontend/src/components/v2/Dropdown/index.tsx | 10 +- frontend/src/components/v2/Menu/Menu.tsx | 69 ++-- .../components/v2/SecretInput/SecretInput.tsx | 18 +- .../src/components/v2/Spinner/Spinner.tsx | 2 +- frontend/src/components/v2/Tag/Tag.tsx | 20 +- frontend/src/components/v2/index.tsx | 1 + frontend/src/helpers/project.ts | 176 +++++---- .../src/hooks/api/secretFolders/index.tsx | 1 - .../src/hooks/api/secretFolders/queries.tsx | 167 ++++----- frontend/src/hooks/api/secretFolders/types.ts | 29 +- .../src/hooks/api/secretImports/mutation.tsx | 26 +- .../src/hooks/api/secretImports/queries.tsx | 90 +++-- frontend/src/hooks/api/secretImports/types.ts | 18 +- .../src/hooks/api/secretSnapshots/index.tsx | 2 +- .../src/hooks/api/secretSnapshots/queries.tsx | 88 ++--- .../src/hooks/api/secretSnapshots/types.ts | 12 +- frontend/src/hooks/api/secrets/index.ts | 14 +- frontend/src/hooks/api/secrets/mutations.tsx | 234 ++++++++++-- frontend/src/hooks/api/secrets/queries.tsx | 335 ++++++------------ frontend/src/hooks/api/secrets/types.ts | 100 +++--- frontend/src/hooks/api/types.ts | 3 + frontend/tailwind.config.js | 2 +- 29 files changed, 1195 insertions(+), 661 deletions(-) create mode 100644 frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx create mode 100644 frontend/src/components/tags/CreateTagModal/index.tsx create mode 100644 frontend/src/components/v2/ContentLoader/ContentLoader.tsx create mode 100644 frontend/src/components/v2/ContentLoader/index.tsx diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index d4a5c4352..f783cdb86 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -1,4 +1,3 @@ -import { useMemo } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; import { faAngleRight } from "@fortawesome/free-solid-svg-icons"; @@ -15,7 +14,7 @@ type Props = { currentEnv?: string; userAvailableEnvs?: any[]; onEnvChange?: (slug: string) => void; - folders?: Array<{ id: string; name: string }>; + secretPath?: string; isFolderMode?: boolean; }; @@ -42,19 +41,14 @@ export default function NavHeader({ currentEnv, userAvailableEnvs = [], onEnvChange, - folders = [], - isFolderMode + isFolderMode, + secretPath = "/" }: Props): JSX.Element { const { currentWorkspace } = useWorkspace(); const { currentOrg } = useOrganization(); const router = useRouter(); - const isInRootFolder = isFolderMode && folders.length <= 1; - - const selectedEnv = useMemo( - () => userAvailableEnvs?.find((uae) => uae.name === currentEnv), - [userAvailableEnvs, currentEnv] - ); + const secretPathSegments = secretPath.split("/").filter(Boolean); return (
@@ -90,13 +84,13 @@ export default function NavHeader({ ) : (
{pageName}
)} - {currentEnv && isInRootFolder && ( + {currentEnv && secretPath === "/" && ( <>
+ + )} + /> +
+
+ Tag Color +
+
+
+
+
+
+ {!showHexInput ? ( +
+ {secretTagsColors.map(($tagColor: TagColor) => { + return ( +
+ +
setValue("color", $tagColor.hex)} + tabIndex={0} + role="button" + onKeyDown={() => {}} + > + {$tagColor.hex === selectedTagColor && ( + + )} +
+
+
+ ); + })} +
+ ) : ( +
+
+ {isValidHexColor(selectedTagColor) && ( +
+ +
+ )} + {!isValidHexColor(selectedTagColor) && ( +
+ )} +
+
+ ) => + setValue("color", e.target.value) + } + /> +
+
+ )} +
+
+
setShowHexInput((prev) => !prev)} + style={{ border: "1px solid rgba(220, 216, 254, 0.376)" }} + tabIndex={0} + role="button" + onKeyDown={() => {}} + > + {!showHexInput && #} +
+
+
+
+
+ +
+ + + + +
+ + + + ); +}; diff --git a/frontend/src/components/tags/CreateTagModal/index.tsx b/frontend/src/components/tags/CreateTagModal/index.tsx new file mode 100644 index 000000000..e9d283b3c --- /dev/null +++ b/frontend/src/components/tags/CreateTagModal/index.tsx @@ -0,0 +1 @@ +export { CreateTagModal } from "./CreateTagModal"; diff --git a/frontend/src/components/v2/Checkbox/Checkbox.tsx b/frontend/src/components/v2/Checkbox/Checkbox.tsx index 8c79a5ff2..87e278119 100644 --- a/frontend/src/components/v2/Checkbox/Checkbox.tsx +++ b/frontend/src/components/v2/Checkbox/Checkbox.tsx @@ -30,9 +30,10 @@ export const Checkbox = ({
{ + const [pos, setPos] = useState(0); + const isTextArray = Array.isArray(text); + useEffect(() => { + let interval: NodeJS.Timer; + if (isTextArray) { + interval = setInterval(() => { + setPos((state) => (state + 1) % text.length); + }, frequency); + } + return () => clearInterval(interval); + }, []); + + return ( +
+
+ loading animation +
+ {text && isTextArray && ( + + + {text[pos]} + + + )} + {text && !isTextArray &&
{text}
} +
+ ); +}; diff --git a/frontend/src/components/v2/ContentLoader/index.tsx b/frontend/src/components/v2/ContentLoader/index.tsx new file mode 100644 index 000000000..78b373523 --- /dev/null +++ b/frontend/src/components/v2/ContentLoader/index.tsx @@ -0,0 +1 @@ +export { ContentLoader } from "./ContentLoader"; diff --git a/frontend/src/components/v2/Dropdown/Dropdown.tsx b/frontend/src/components/v2/Dropdown/Dropdown.tsx index 8d2427c1c..057a7b4a6 100644 --- a/frontend/src/components/v2/Dropdown/Dropdown.tsx +++ b/frontend/src/components/v2/Dropdown/Dropdown.tsx @@ -6,6 +6,9 @@ import { twMerge } from "tailwind-merge"; export type DropdownMenuProps = DropdownMenuPrimitive.DropdownMenuProps; export const DropdownMenu = DropdownMenuPrimitive.Root; +export type DropdownSubMenuProps = DropdownMenuPrimitive.DropdownMenuSubProps; +export const DropdownSubMenu = DropdownMenuPrimitive.Sub; + // trigger export type DropdownMenuTriggerProps = DropdownMenuPrimitive.DropdownMenuTriggerProps; export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; @@ -34,6 +37,30 @@ export const DropdownMenuContent = forwardRef( + ({ children, className, ...props }, forwardedRef) => { + return ( + + + {children} + + + ); + } +); + +DropdownSubMenuContent.displayName = "DropdownMenuContent"; + // item label component export type DropdownLabelProps = DropdownMenuPrimitive.MenuLabelProps; export const DropdownMenuLabel = ({ className, ...props }: DropdownLabelProps) => ( @@ -76,11 +103,50 @@ export const DropdownMenuItem = ({ ); +// trigger +export type DropdownSubMenuTriggerProps = + DropdownMenuPrimitive.DropdownMenuSubTriggerProps & { + icon?: ReactNode; + as?: T; + inputRef?: Ref; + iconPos?: "left" | "right"; + }; + +export const DropdownSubMenuTrigger = ({ + children, + inputRef, + className, + icon, + as: Item = "button", + iconPos = "left", + ...props +}: DropdownMenuItemProps & ComponentPropsWithRef) => ( + + + {icon && iconPos === "left" && {icon}} + {children} + {icon && iconPos === "right" && {icon}} + + +); + // grouping items into 1 export type DropdownMenuGroupProps = DropdownMenuPrimitive.DropdownMenuGroupProps; export const DropdownMenuGroup = forwardRef( - ({ ...props }, ref) => + ({ ...props }, ref) => ( + + ) ); DropdownMenuGroup.displayName = "DropdownMenuGroup"; @@ -98,3 +164,5 @@ export const DropdownMenuSeparator = forwardRef< )); DropdownMenuSeparator.displayName = "DropdownMenuSeperator"; + +DropdownMenuSeparator.displayName = "DropdownMenuSeperator"; diff --git a/frontend/src/components/v2/Dropdown/index.tsx b/frontend/src/components/v2/Dropdown/index.tsx index 8ecbebd09..bde01449e 100644 --- a/frontend/src/components/v2/Dropdown/index.tsx +++ b/frontend/src/components/v2/Dropdown/index.tsx @@ -4,7 +4,10 @@ export type { DropdownMenuGroupProps, DropdownMenuItemProps, DropdownMenuProps, - DropdownMenuTriggerProps + DropdownMenuTriggerProps, + DropdownSubMenuContentProps, + DropdownSubMenuProps, + DropdownSubMenuTriggerProps } from "./Dropdown"; export { DropdownMenu, @@ -13,5 +16,8 @@ export { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuTrigger + DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger } from "./Dropdown"; diff --git a/frontend/src/components/v2/Menu/Menu.tsx b/frontend/src/components/v2/Menu/Menu.tsx index d56d16fec..e4771e551 100644 --- a/frontend/src/components/v2/Menu/Menu.tsx +++ b/frontend/src/components/v2/Menu/Menu.tsx @@ -2,8 +2,8 @@ /* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable global-require */ import { ComponentPropsWithRef, ElementType, ReactNode, Ref, useRef } from "react"; -import { motion } from "framer-motion" -import Lottie from "lottie-react" +import { motion } from "framer-motion"; +import Lottie from "lottie-react"; import { twMerge } from "tailwind-merge"; export type MenuProps = { @@ -39,13 +39,10 @@ export const MenuItem = ({ inputRef, ...props }: MenuItemProps & ComponentPropsWithRef): JSX.Element => { - const iconRef = useRef() + const iconRef = useRef(); - return( -
iconRef.current?.play()} - onMouseLeave={() => iconRef.current?.stop()} - > + return ( + iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
  • ({ )} > - -
    - {/* {icon && {icon}} */} - +
    + {/* {icon && {icon}} */} + {icon && ( + + )} {children} {description && {description}}
  • - ) + ); }; export const SubMenuItem = ({ @@ -88,13 +97,10 @@ export const SubMenuItem = ({ inputRef, ...props }: MenuItemProps & ComponentPropsWithRef): JSX.Element => { - const iconRef = useRef() + const iconRef = useRef(); - return( - iconRef.current?.play()} - onMouseLeave={() => iconRef.current?.stop()} - > + return ( + iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
  • ({ )} > - + ({
  • - ) + ); }; - MenuItem.displayName = "MenuItem"; export type MenuGroupProps = { diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index d7476ec57..c09061c4f 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -1,6 +1,7 @@ /* eslint-disable react/no-danger */ -import { forwardRef, HTMLAttributes } from "react"; +import { forwardRef, TextareaHTMLAttributes } from "react"; import sanitizeHtml, { DisallowedTagsModes } from "sanitize-html"; +import { twMerge } from "tailwind-merge"; import { useToggle } from "@app/hooks"; @@ -39,20 +40,28 @@ const syntaxHighlight = (content?: string | null, isVisible?: boolean) => { return `${newContent}
    `; }; -type Props = HTMLAttributes & { +type Props = TextareaHTMLAttributes & { value?: string | null; isVisible?: boolean; + isReadOnly?: boolean; isDisabled?: boolean; + containerClassName?: string; }; const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all"; export const SecretInput = forwardRef( - ({ value, isVisible, onBlur, isDisabled, onFocus, ...props }, ref) => { + ( + { value, isVisible, containerClassName, onBlur, isDisabled, isReadOnly, onFocus, ...props }, + ref + ) => { const [isSecretFocused, setIsSecretFocused] = useToggle(); return ( -
    +
                 
    @@ -78,6 +87,7 @@ export const SecretInput = forwardRef(
                 }}
                 value={value || ""}
                 {...props}
    +            readOnly={isReadOnly}
               />
             
    diff --git a/frontend/src/components/v2/Spinner/Spinner.tsx b/frontend/src/components/v2/Spinner/Spinner.tsx index ce877cff2..4f0cb15aa 100644 --- a/frontend/src/components/v2/Spinner/Spinner.tsx +++ b/frontend/src/components/v2/Spinner/Spinner.tsx @@ -20,7 +20,7 @@ export const Spinner = ({ className, size = "md" }: Props): JSX.Element => {
    +export const Tag = ({ children, className, colorSchema = "gray", size = "sm", onClose }: Props) => ( +
    {children} + {onClose && ( + + )}
    ); diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index eb49f0d88..89030931d 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -2,6 +2,7 @@ export * from "./Accordion"; export * from "./Button"; export * from "./Card"; export * from "./Checkbox"; +export * from "./ContentLoader"; export * from "./DatePicker"; export * from "./DeleteActionModal"; export * from "./Drawer"; diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 6a1efc135..13352c586 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -3,77 +3,75 @@ import crypto from "crypto"; import { encryptAssymmetric } from "@app/components/utilities/cryptography/crypto"; import encryptSecrets from "@app/components/utilities/secrets/encryptSecrets"; import { uploadWsKey } from "@app/hooks/api/keys/queries"; -import { createSecret } from "@app/hooks/api/secrets/queries"; +import { createSecret } from "@app/hooks/api/secrets/mutations"; import { fetchUserDetails } from "@app/hooks/api/users/queries"; import { createWorkspace } from "@app/hooks/api/workspace/queries"; const secretsToBeAdded = [ - { - pos: 0, - key: "DATABASE_URL", - // eslint-disable-next-line no-template-curly-in-string - value: "mongodb+srv://${DB_USERNAME}:${DB_PASSWORD}@mongodb.net", - valueOverride: undefined, - comment: "Secret referencing example", - id: "", - tags: [] - }, - { - pos: 1, - key: "DB_USERNAME", - value: "OVERRIDE_THIS", - valueOverride: undefined, - comment: - "Override secrets with personal value", - id: "", - tags: [] - }, - { - pos: 2, - key: "DB_PASSWORD", - value: "OVERRIDE_THIS", - valueOverride: undefined, - comment: - "Another secret override", - id: "", - tags: [] - }, - { - pos: 3, - key: "DB_USERNAME", - value: "user1234", - valueOverride: "user1234", - comment: "", - id: "", - tags: [] - }, - { - pos: 4, - key: "DB_PASSWORD", - value: "example_password", - valueOverride: "example_password", - comment: "", - id: "", - tags: [] - }, - { - pos: 5, - key: "TWILIO_AUTH_TOKEN", - value: "example_twillio_token", - valueOverride: undefined, - comment: "", - id: "", - tags: [] - }, - { - pos: 6, - key: "WEBSITE_URL", - value: "http://localhost:3000", - valueOverride: undefined, - comment: "", - id: "", - tags: [] - } + { + pos: 0, + key: "DATABASE_URL", + // eslint-disable-next-line no-template-curly-in-string + value: "mongodb+srv://${DB_USERNAME}:${DB_PASSWORD}@mongodb.net", + valueOverride: undefined, + comment: "Secret referencing example", + id: "", + tags: [] + }, + { + pos: 1, + key: "DB_USERNAME", + value: "OVERRIDE_THIS", + valueOverride: undefined, + comment: "Override secrets with personal value", + id: "", + tags: [] + }, + { + pos: 2, + key: "DB_PASSWORD", + value: "OVERRIDE_THIS", + valueOverride: undefined, + comment: "Another secret override", + id: "", + tags: [] + }, + { + pos: 3, + key: "DB_USERNAME", + value: "user1234", + valueOverride: "user1234", + comment: "", + id: "", + tags: [] + }, + { + pos: 4, + key: "DB_PASSWORD", + value: "example_password", + valueOverride: "example_password", + comment: "", + id: "", + tags: [] + }, + { + pos: 5, + key: "TWILIO_AUTH_TOKEN", + value: "example_twillio_token", + valueOverride: undefined, + comment: "", + id: "", + tags: [] + }, + { + pos: 6, + key: "WEBSITE_URL", + value: "http://localhost:3000", + valueOverride: undefined, + comment: "", + id: "", + tags: [] + } ]; /** @@ -85,30 +83,32 @@ const secretsToBeAdded = [ * @returns {Project} project - new project */ const initProjectHelper = async ({ - organizationId, - projectName + organizationId, + projectName }: { - organizationId: string; - projectName: string; + organizationId: string; + projectName: string; }) => { // create new project - const { data: { workspace } } = await createWorkspace({ - workspaceName: projectName, - organizationId + const { + data: { workspace } + } = await createWorkspace({ + workspaceName: projectName, + organizationId }); - + // create and upload new (encrypted) project key const randomBytes = crypto.randomBytes(16).toString("hex"); const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); - + if (!PRIVATE_KEY) throw new Error("Failed to find private key"); const user = await fetchUserDetails(); const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: randomBytes, - publicKey: user.publicKey, - privateKey: PRIVATE_KEY + plaintext: randomBytes, + publicKey: user.publicKey, + privateKey: PRIVATE_KEY }); await uploadWsKey({ @@ -120,11 +120,11 @@ const initProjectHelper = async ({ // encrypt and upload secrets to new project const secrets = await encryptSecrets({ - secretsToEncrypt: secretsToBeAdded, - workspaceId: workspace._id, - env: "dev" + secretsToEncrypt: secretsToBeAdded, + workspaceId: workspace._id, + env: "dev" }); - + secrets?.forEach((secret) => { createSecret({ workspaceId: workspace._id, @@ -146,10 +146,8 @@ const initProjectHelper = async ({ } }); }); - - return workspace; -} -export { - initProjectHelper -} \ No newline at end of file + return workspace; +}; + +export { initProjectHelper }; diff --git a/frontend/src/hooks/api/secretFolders/index.tsx b/frontend/src/hooks/api/secretFolders/index.tsx index 3c0692eb6..0676f5d14 100644 --- a/frontend/src/hooks/api/secretFolders/index.tsx +++ b/frontend/src/hooks/api/secretFolders/index.tsx @@ -3,6 +3,5 @@ export { useDeleteFolder, useGetFoldersByEnv, useGetProjectFolders, - useGetProjectFoldersBatch, useUpdateFolder } from "./queries"; diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 36d013b72..e562e0105 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -1,86 +1,80 @@ import { useCallback, useMemo } from "react"; -import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQueries, + useQuery, + useQueryClient, + UseQueryOptions +} from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { secretSnapshotKeys } from "../secretSnapshots/queries"; import { - CreateFolderDTO, - DeleteFolderDTO, - GetProjectFoldersBatchDTO, - GetProjectFoldersDTO, + TCreateFolderDTO, + TDeleteFolderDTO, TGetFoldersByEnvDTO, + TGetProjectFoldersDTO, TSecretFolder, - UpdateFolderDTO + TUpdateFolderDTO } from "./types"; const queryKeys = { - getSecretFolders: (workspaceId: string, environment: string, parentFolderId?: string) => - ["secret-folders", { workspaceId, environment, parentFolderId }] as const + getSecretFolders: ({ workspaceId, environment, directory }: TGetProjectFoldersDTO) => + ["secret-folders", { workspaceId, environment, directory }] as const }; -const fetchProjectFolders = async ( - workspaceId: string, - environment: string, - parentFolderId?: string, - parentFolderPath?: string -) => { - const { data } = await apiRequest.get<{ folders: TSecretFolder[]; dir: TSecretFolder[] }>( - "/api/v1/folders", - { - params: { - workspaceId, - environment, - parentFolderId, - parentFolderPath - } +const fetchProjectFolders = async (workspaceId: string, environment: string, directory = "/") => { + const { data } = await apiRequest.get<{ folders: TSecretFolder[] }>("/api/v1/folders", { + params: { + workspaceId, + environment, + directory } - ); - return data; + }); + return data.folders; }; export const useGetProjectFolders = ({ workspaceId, - parentFolderId, environment, - isPaused, - sortDir -}: GetProjectFoldersDTO) => + directory = "/", + options = {} +}: TGetProjectFoldersDTO & { + options?: Omit< + UseQueryOptions< + TSecretFolder[], + unknown, + TSecretFolder[], + ReturnType + >, + "queryKey" | "queryFn" + >; +}) => useQuery({ - queryKey: queryKeys.getSecretFolders(workspaceId, environment, parentFolderId), - enabled: Boolean(workspaceId) && Boolean(environment) && !isPaused, - queryFn: async () => fetchProjectFolders(workspaceId, environment, parentFolderId), - select: useCallback( - ({ folders, dir }: { folders: TSecretFolder[]; dir: TSecretFolder[] }) => ({ - dir, - folders: folders.sort((a, b) => - sortDir === "asc" - ? a?.name?.localeCompare(b?.name || "") - : b?.name?.localeCompare(a?.name || "") - ) - }), - [sortDir] - ) + ...options, + queryKey: queryKeys.getSecretFolders({ workspaceId, environment, directory }), + enabled: Boolean(workspaceId) && Boolean(environment) && (options?.enabled ?? true), + queryFn: async () => fetchProjectFolders(workspaceId, environment, directory) }); export const useGetFoldersByEnv = ({ - parentFolderPath, + directory = "/", workspaceId, - environments, - parentFolderId + environments }: TGetFoldersByEnvDTO) => { const folders = useQueries({ - queries: environments.map((env) => ({ - queryKey: queryKeys.getSecretFolders(workspaceId, env, parentFolderPath || parentFolderId), - queryFn: async () => fetchProjectFolders(workspaceId, env, parentFolderId, parentFolderPath), - enabled: Boolean(workspaceId) && Boolean(env) + queries: environments.map((environment) => ({ + queryKey: queryKeys.getSecretFolders({ workspaceId, environment, directory }), + queryFn: async () => fetchProjectFolders(workspaceId, environment, directory), + enabled: Boolean(workspaceId) && Boolean(environment) })) }); const folderNames = useMemo(() => { const names = new Set(); folders?.forEach(({ data }) => { - data?.folders.forEach(({ name }) => { + data?.forEach(({ name }) => { names.add(name); }); }); @@ -92,9 +86,7 @@ export const useGetFoldersByEnv = ({ const selectedEnvIndex = environments.indexOf(env); if (selectedEnvIndex !== -1) { return Boolean( - folders?.[selectedEnvIndex]?.data?.folders?.find( - ({ name: folderName }) => folderName === name - ) + folders?.[selectedEnvIndex]?.data?.find(({ name: folderName }) => folderName === name) ); } return false; @@ -105,95 +97,78 @@ export const useGetFoldersByEnv = ({ return { folders, folderNames, isFolderPresentInEnv }; }; -export const useGetProjectFoldersBatch = ({ - folders = [], - isPaused, - parentFolderPath -}: GetProjectFoldersBatchDTO) => - useQueries({ - queries: folders.map(({ workspaceId, environment, parentFolderId }) => ({ - queryKey: queryKeys.getSecretFolders(workspaceId, environment, parentFolderPath), - queryFn: async () => - fetchProjectFolders(workspaceId, environment, parentFolderId, parentFolderPath), - enabled: Boolean(workspaceId) && Boolean(environment) && !isPaused, - select: (data: { folders: TSecretFolder[]; dir: TSecretFolder[] }) => ({ - environment, - folders: data.folders, - dir: data.dir - }) - })) - }); - export const useCreateFolder = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, CreateFolderDTO>({ + return useMutation<{}, {}, TCreateFolderDTO>({ mutationFn: async (dto) => { const { data } = await apiRequest.post("/api/v1/folders", dto); return data; }, - onSuccess: (_, { workspaceId, environment, parentFolderId }) => { + onSuccess: (_, { workspaceId, environment, directory }) => { queryClient.invalidateQueries( - queryKeys.getSecretFolders(workspaceId, environment, parentFolderId) + queryKeys.getSecretFolders({ workspaceId, environment, directory }) ); queryClient.invalidateQueries( - secretSnapshotKeys.count(workspaceId, environment, parentFolderId) + secretSnapshotKeys.list({ workspaceId, environment, directory }) ); queryClient.invalidateQueries( - secretSnapshotKeys.list(workspaceId, environment, parentFolderId) + secretSnapshotKeys.count({ workspaceId, environment, directory }) ); } }); }; -export const useUpdateFolder = (parentFolderId: string) => { +export const useUpdateFolder = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, UpdateFolderDTO>({ - mutationFn: async ({ folderId, name, environment, workspaceId }) => { - const { data } = await apiRequest.patch(`/api/v1/folders/${folderId}`, { + return useMutation<{}, {}, TUpdateFolderDTO>({ + mutationFn: async ({ directory = "/", folderName, name, environment, workspaceId }) => { + const { data } = await apiRequest.patch(`/api/v1/folders/${folderName}`, { name, environment, - workspaceId + workspaceId, + directory }); return data; }, - onSuccess: (_, { workspaceId, environment }) => { + onSuccess: (_, { workspaceId, environment, directory }) => { queryClient.invalidateQueries( - queryKeys.getSecretFolders(workspaceId, environment, parentFolderId) + queryKeys.getSecretFolders({ workspaceId, environment, directory }) ); queryClient.invalidateQueries( - secretSnapshotKeys.count(workspaceId, environment, parentFolderId) + secretSnapshotKeys.list({ workspaceId, environment, directory }) ); queryClient.invalidateQueries( - secretSnapshotKeys.list(workspaceId, environment, parentFolderId) + secretSnapshotKeys.count({ workspaceId, environment, directory }) ); } }); }; -export const useDeleteFolder = (parentFolderId: string) => { +export const useDeleteFolder = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, DeleteFolderDTO>({ - mutationFn: async ({ folderId, environment, workspaceId }) => { - const { data } = await apiRequest.delete(`/api/v1/folders/${folderId}`, { + return useMutation<{}, {}, TDeleteFolderDTO>({ + mutationFn: async ({ directory = "/", folderName, environment, workspaceId }) => { + const { data } = await apiRequest.delete(`/api/v1/folders/${folderName}`, { data: { environment, - workspaceId + workspaceId, + directory } }); return data; }, - onSuccess: (_, { workspaceId, environment }) => { + onSuccess: (_, { directory = "/", workspaceId, environment }) => { queryClient.invalidateQueries( - queryKeys.getSecretFolders(workspaceId, environment, parentFolderId) + queryKeys.getSecretFolders({ workspaceId, environment, directory }) ); queryClient.invalidateQueries( - secretSnapshotKeys.count(workspaceId, environment, parentFolderId) + secretSnapshotKeys.list({ workspaceId, environment, directory }) ); queryClient.invalidateQueries( - secretSnapshotKeys.list(workspaceId, environment, parentFolderId) + secretSnapshotKeys.count({ workspaceId, environment, directory }) ); } }); diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index c9d707620..c051173e9 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -3,43 +3,36 @@ export type TSecretFolder = { name: string; }; -export type GetProjectFoldersDTO = { +export type TGetProjectFoldersDTO = { workspaceId: string; environment: string; - parentFolderId?: string; - isPaused?: boolean; - sortDir?: "asc" | "desc"; -}; - -export type GetProjectFoldersBatchDTO = { - folders: Omit[]; - isPaused?: boolean; - parentFolderPath?: string; + directory?: string; }; export type TGetFoldersByEnvDTO = { environments: string[]; workspaceId: string; - parentFolderPath?: string; - parentFolderId?: string; + directory?: string; }; -export type CreateFolderDTO = { +export type TCreateFolderDTO = { workspaceId: string; environment: string; folderName: string; - parentFolderId?: string; + directory?: string; }; -export type UpdateFolderDTO = { +export type TUpdateFolderDTO = { workspaceId: string; environment: string; name: string; - folderId: string; + folderName: string; + directory?: string; }; -export type DeleteFolderDTO = { +export type TDeleteFolderDTO = { workspaceId: string; environment: string; - folderId: string; + folderName: string; + directory?: string; }; diff --git a/frontend/src/hooks/api/secretImports/mutation.tsx b/frontend/src/hooks/api/secretImports/mutation.tsx index bb01528ea..184c8dd63 100644 --- a/frontend/src/hooks/api/secretImports/mutation.tsx +++ b/frontend/src/hooks/api/secretImports/mutation.tsx @@ -9,21 +9,21 @@ export const useCreateSecretImport = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateSecretImportDTO>({ - mutationFn: async ({ secretImport, environment, workspaceId, folderId }) => { + mutationFn: async ({ secretImport, environment, workspaceId, directory }) => { const { data } = await apiRequest.post("/api/v1/secret-imports", { secretImport, environment, workspaceId, - folderId + directory }); return data; }, - onSuccess: (_, { workspaceId, environment, folderId }) => { + onSuccess: (_, { workspaceId, environment, directory }) => { queryClient.invalidateQueries( - secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId) + secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory }) ); queryClient.invalidateQueries( - secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId) + secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory }) ); } }); @@ -33,21 +33,21 @@ export const useUpdateSecretImport = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TUpdateSecretImportDTO>({ - mutationFn: async ({ environment, workspaceId, folderId, secretImports, id }) => { + mutationFn: async ({ environment, workspaceId, directory, secretImports, id }) => { const { data } = await apiRequest.put(`/api/v1/secret-imports/${id}`, { secretImports, environment, workspaceId, - folderId + directory }); return data; }, - onSuccess: (_, { workspaceId, environment, folderId }) => { + onSuccess: (_, { workspaceId, environment, directory }) => { queryClient.invalidateQueries( - secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId) + secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory }) ); queryClient.invalidateQueries( - secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId) + secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory }) ); } }); @@ -66,12 +66,12 @@ export const useDeleteSecretImport = () => { }); return data; }, - onSuccess: (_, { workspaceId, environment, folderId }) => { + onSuccess: (_, { workspaceId, environment, directory }) => { queryClient.invalidateQueries( - secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId) + secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory }) ); queryClient.invalidateQueries( - secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId) + secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory }) ); } }); diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx index 7bb07488c..75c1820cc 100644 --- a/frontend/src/hooks/api/secretImports/queries.tsx +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -1,5 +1,5 @@ import { useCallback } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { decryptAssymmetric, @@ -7,52 +7,68 @@ import { } from "@app/components/utilities/cryptography/crypto"; import { apiRequest } from "@app/config/request"; -import { TGetImportedSecrets, TImportedSecrets, TSecretImports } from "./types"; +import { TGetImportedSecrets, TGetSecretImports, TImportedSecrets, TSecretImports } from "./types"; export const secretImportKeys = { - getProjectSecretImports: (workspaceId: string, env: string | string[], folderId?: string) => [ - { workspaceId, env, folderId }, - "secrets-imports" - ], - getSecretImportSecrets: (workspaceId: string, env: string | string[], folderId?: string) => [ - { workspaceId, env, folderId }, - "secrets-import-sec" - ] + getProjectSecretImports: ({ environment, workspaceId, directory }: TGetSecretImports) => + [{ workspaceId, directory, environment }, "secrets-imports"] as const, + getSecretImportSecrets: ({ + workspaceId, + environment, + directory + }: Omit) => + [{ workspaceId, environment, directory }, "secrets-import-sec"] as const }; -const fetchSecretImport = async (workspaceId: string, environment: string, folderId?: string) => { +const fetchSecretImport = async ({ workspaceId, environment, directory }: TGetSecretImports) => { const { data } = await apiRequest.get<{ secretImport: TSecretImports }>( "/api/v1/secret-imports", { params: { workspaceId, environment, - folderId + directory } } ); return data.secretImport; }; -export const useGetSecretImports = (workspaceId: string, env: string, folderId?: string) => +export const useGetSecretImports = ({ + workspaceId, + environment, + directory = "/", + options = {} +}: TGetSecretImports & { + options?: Omit< + UseQueryOptions< + TSecretImports, + unknown, + TSecretImports, + ReturnType + >, + "queryKey" | "queryFn" + >; +}) => useQuery({ - enabled: Boolean(workspaceId) && Boolean(env), - queryKey: secretImportKeys.getProjectSecretImports(workspaceId, env, folderId), - queryFn: () => fetchSecretImport(workspaceId, env, folderId) + ...options, + queryKey: secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory }), + enabled: Boolean(workspaceId) && Boolean(environment) && (options?.enabled ?? true), + queryFn: () => fetchSecretImport({ workspaceId, environment, directory }) }); const fetchImportedSecrets = async ( workspaceId: string, environment: string, - folderId?: string + directory?: string ) => { - const { data } = await apiRequest.get<{ secrets: TImportedSecrets }>( + const { data } = await apiRequest.get<{ secrets: TImportedSecrets[] }>( "/api/v1/secret-imports/secrets", { params: { workspaceId, environment, - folderId + directory } } ); @@ -62,15 +78,34 @@ const fetchImportedSecrets = async ( export const useGetImportedSecrets = ({ workspaceId, environment, - folderId, - decryptFileKey -}: TGetImportedSecrets) => + decryptFileKey, + directory, + options = {} +}: TGetImportedSecrets & { + options?: Omit< + UseQueryOptions< + TImportedSecrets[], + unknown, + TImportedSecrets[], + ReturnType + >, + "queryKey" | "queryFn" + >; +}) => useQuery({ - enabled: Boolean(workspaceId) && Boolean(environment) && Boolean(decryptFileKey), - queryKey: secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId), - queryFn: () => fetchImportedSecrets(workspaceId, environment, folderId), + enabled: + Boolean(workspaceId) && + Boolean(environment) && + Boolean(decryptFileKey) && + (options?.enabled ?? true), + queryKey: secretImportKeys.getSecretImportSecrets({ + workspaceId, + environment, + directory + }), + queryFn: () => fetchImportedSecrets(workspaceId, environment, directory), select: useCallback( - (data: TImportedSecrets) => { + (data: TImportedSecrets[]) => { const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const latestKey = decryptFileKey; const key = decryptAssymmetric({ @@ -114,7 +149,8 @@ export const useGetImportedSecrets = ({ tags: encSecret.tags, comment: secretComment, createdAt: encSecret.createdAt, - updatedAt: encSecret.updatedAt + updatedAt: encSecret.updatedAt, + version: encSecret.version }; }) })); diff --git a/frontend/src/hooks/api/secretImports/types.ts b/frontend/src/hooks/api/secretImports/types.ts index 50884225f..019401092 100644 --- a/frontend/src/hooks/api/secretImports/types.ts +++ b/frontend/src/hooks/api/secretImports/types.ts @@ -1,5 +1,5 @@ +import { UserWsKeyPair } from "../keys/types"; import { EncryptedSecret } from "../secrets/types"; -import { UserWsKeyPair } from "../types"; export type TSecretImports = { _id: string; @@ -16,19 +16,25 @@ export type TImportedSecrets = { secretPath: string; folderId: string; secrets: EncryptedSecret[]; -}[]; +}; + +export type TGetSecretImports = { + workspaceId: string; + environment: string; + directory?: string; +}; export type TGetImportedSecrets = { workspaceId: string; environment: string; - folderId?: string; + directory?: string; decryptFileKey: UserWsKeyPair; }; export type TCreateSecretImportDTO = { workspaceId: string; environment: string; - folderId?: string; + directory?: string; secretImport: { environment: string; secretPath: string; @@ -39,7 +45,7 @@ export type TUpdateSecretImportDTO = { id: string; workspaceId: string; environment: string; - folderId?: string; + directory?: string; secretImports: Array<{ environment: string; secretPath: string; @@ -50,7 +56,7 @@ export type TDeleteSecretImportDTO = { id: string; workspaceId: string; environment: string; - folderId?: string; + directory?: string; secretImportPath: string; secretImportEnv: string; }; diff --git a/frontend/src/hooks/api/secretSnapshots/index.tsx b/frontend/src/hooks/api/secretSnapshots/index.tsx index 0e308b6f8..e6d4ec9b0 100644 --- a/frontend/src/hooks/api/secretSnapshots/index.tsx +++ b/frontend/src/hooks/api/secretSnapshots/index.tsx @@ -1,6 +1,6 @@ export { useGetSnapshotSecrets, - useGetWorkspaceSecretSnapshots, + useGetWorkspaceSnapshotList, useGetWsSnapshotCount, usePerformSecretRollback } from "./queries"; diff --git a/frontend/src/hooks/api/secretSnapshots/queries.tsx b/frontend/src/hooks/api/secretSnapshots/queries.tsx index cf1103910..13db97aea 100644 --- a/frontend/src/hooks/api/secretSnapshots/queries.tsx +++ b/frontend/src/hooks/api/secretSnapshots/queries.tsx @@ -9,39 +9,39 @@ import { apiRequest } from "@app/config/request"; import { DecryptedSecret } from "../secrets/types"; import { - GetWorkspaceSecretSnapshotsDTO, + TGetSecretSnapshotsDTO, TSecretRollbackDTO, - TSnapshotSecret, - TSnapshotSecretProps, - TWorkspaceSecretSnapshot + TSecretSnapshot, + TSnapshotData, + TSnapshotDataProps } from "./types"; export const secretSnapshotKeys = { - list: (workspaceId: string, env: string, folderId?: string) => - [{ workspaceId, env, folderId }, "secret-snapshot"] as const, - snapshotSecrets: (snapshotId: string) => [{ snapshotId }, "secret-snapshot"] as const, - count: (workspaceId: string, env: string, folderId?: string) => [ - { workspaceId, env, folderId }, + list: ({ workspaceId, environment, directory }: Omit) => + [{ workspaceId, environment, directory }, "secret-snapshot"] as const, + snapshotData: (snapshotId: string) => [{ snapshotId }, "secret-snapshot"] as const, + count: ({ environment, workspaceId, directory }: Omit) => [ + { workspaceId, environment, directory }, "count", "secret-snapshot" ] }; -const fetchWorkspaceSecretSnaphots = async ( - workspaceId: string, - environment: string, - folderId?: string, +const fetchWorkspaceSnaphots = async ({ + workspaceId, + environment, + directory = "/", limit = 10, offset = 0 -) => { - const res = await apiRequest.get<{ secretSnapshots: TWorkspaceSecretSnapshot[] }>( +}: TGetSecretSnapshotsDTO & { offset: number }) => { + const res = await apiRequest.get<{ secretSnapshots: TSecretSnapshot[] }>( `/api/v1/workspace/${workspaceId}/secret-snapshots`, { params: { limit, offset, environment, - folderId + directory } } ); @@ -49,32 +49,25 @@ const fetchWorkspaceSecretSnaphots = async ( return res.data.secretSnapshots; }; -export const useGetWorkspaceSecretSnapshots = (dto: GetWorkspaceSecretSnapshotsDTO) => +export const useGetWorkspaceSnapshotList = (dto: TGetSecretSnapshotsDTO & { isPaused?: boolean }) => useInfiniteQuery({ - enabled: Boolean(dto.workspaceId && dto.environment), - queryKey: secretSnapshotKeys.list(dto.workspaceId, dto.environment, dto?.folder), - queryFn: ({ pageParam }) => - fetchWorkspaceSecretSnaphots( - dto.workspaceId, - dto.environment, - dto?.folder, - dto.limit, - pageParam - ), + enabled: Boolean(dto.workspaceId && dto.environment) && !dto.isPaused, + queryKey: secretSnapshotKeys.list({ ...dto }), + queryFn: ({ pageParam }) => fetchWorkspaceSnaphots({ ...dto, offset: pageParam }), getNextPageParam: (lastPage, pages) => lastPage.length !== 0 ? pages.length * dto.limit : undefined }); const fetchSnapshotEncSecrets = async (snapshotId: string) => { - const res = await apiRequest.get<{ secretSnapshot: TSnapshotSecret }>( + const res = await apiRequest.get<{ secretSnapshot: TSnapshotData }>( `/api/v1/secret-snapshot/${snapshotId}` ); return res.data.secretSnapshot; }; -export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnapshotSecretProps) => +export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnapshotDataProps) => useQuery({ - queryKey: secretSnapshotKeys.snapshotSecrets(snapshotId), + queryKey: secretSnapshotKeys.snapshotData(snapshotId), enabled: Boolean(snapshotId && decryptFileKey), queryFn: () => fetchSnapshotEncSecrets(snapshotId), select: (data) => { @@ -117,7 +110,8 @@ export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnap comment: secretComment, createdAt: encSecret.createdAt, updatedAt: encSecret.updatedAt, - type: "modified" + type: "modified", + version: encSecret.version }; if (encSecret.type === "personal") { @@ -147,25 +141,30 @@ export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnap const fetchWorkspaceSecretSnaphotCount = async ( workspaceId: string, environment: string, - folderId?: string + directory = "/" ) => { const res = await apiRequest.get<{ count: number }>( `/api/v1/workspace/${workspaceId}/secret-snapshots/count`, { params: { environment, - folderId + directory } } ); return res.data.count; }; -export const useGetWsSnapshotCount = (workspaceId: string, env: string, folderId?: string) => +export const useGetWsSnapshotCount = ({ + workspaceId, + environment, + directory, + isPaused +}: Omit & { isPaused?: boolean }) => useQuery({ - enabled: Boolean(workspaceId && env), - queryKey: secretSnapshotKeys.count(workspaceId, env, folderId), - queryFn: () => fetchWorkspaceSecretSnaphotCount(workspaceId, env, folderId) + enabled: Boolean(workspaceId && environment) && !isPaused, + queryKey: secretSnapshotKeys.count({ workspaceId, environment, directory }), + queryFn: () => fetchWorkspaceSecretSnaphotCount(workspaceId, environment, directory) }); export const usePerformSecretRollback = () => { @@ -179,10 +178,17 @@ export const usePerformSecretRollback = () => { ); return data; }, - onSuccess: (_, { workspaceId, environment, folderId }) => { - queryClient.invalidateQueries([{ workspaceId, environment }, "secrets"]); - queryClient.invalidateQueries(secretSnapshotKeys.list(workspaceId, environment, folderId)); - queryClient.invalidateQueries(secretSnapshotKeys.count(workspaceId, environment, folderId)); + onSuccess: (_, { workspaceId, environment, directory }) => { + queryClient.invalidateQueries([ + { workspaceId, environment, secretPath: directory }, + "secrets" + ]); + queryClient.invalidateQueries( + secretSnapshotKeys.list({ workspaceId, environment, directory }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ workspaceId, environment, directory }) + ); } }); }; diff --git a/frontend/src/hooks/api/secretSnapshots/types.ts b/frontend/src/hooks/api/secretSnapshots/types.ts index 98f7b5b24..8fa5f9451 100644 --- a/frontend/src/hooks/api/secretSnapshots/types.ts +++ b/frontend/src/hooks/api/secretSnapshots/types.ts @@ -1,7 +1,7 @@ import { UserWsKeyPair } from "../keys/types"; import { EncryptedSecretVersion } from "../secrets/types"; -export type TWorkspaceSecretSnapshot = { +export type TSecretSnapshot = { _id: string; workspace: string; version: number; @@ -11,27 +11,27 @@ export type TWorkspaceSecretSnapshot = { __v: number; }; -export type TSnapshotSecret = Omit & { +export type TSnapshotData = Omit & { secretVersions: EncryptedSecretVersion[]; folderVersion: Array<{ name: string; id: string }>; }; -export type TSnapshotSecretProps = { +export type TSnapshotDataProps = { snapshotId: string; env: string; decryptFileKey: UserWsKeyPair; }; -export type GetWorkspaceSecretSnapshotsDTO = { +export type TGetSecretSnapshotsDTO = { workspaceId: string; limit: number; environment: string; - folder?: string; + directory?: string; }; export type TSecretRollbackDTO = { workspaceId: string; version: number; environment: string; - folderId?: string; + directory?: string; }; diff --git a/frontend/src/hooks/api/secrets/index.ts b/frontend/src/hooks/api/secrets/index.ts index 2e31e4f0b..b24b5ed19 100644 --- a/frontend/src/hooks/api/secrets/index.ts +++ b/frontend/src/hooks/api/secrets/index.ts @@ -1,7 +1,9 @@ -export { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "./mutations"; export { - useBatchSecretsOp, - useGetProjectSecrets, - useGetProjectSecretsAllEnv, - useGetSecretVersion -} from "./queries"; + useCreateSecretBatch, + useCreateSecretV3, + useDeleteSecretBatch, + useDeleteSecretV3, + useUpdateSecretBatch, + useUpdateSecretV3 +} from "./mutations"; +export { useGetProjectSecrets, useGetProjectSecretsAllEnv, useGetSecretVersion } from "./queries"; diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 6df00174c..c1bf59c85 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -1,6 +1,6 @@ import crypto from "crypto"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-query"; import { decryptAssymmetric, @@ -8,8 +8,17 @@ import { } from "@app/components/utilities/cryptography/crypto"; import { apiRequest } from "@app/config/request"; +import { secretSnapshotKeys } from "../secretSnapshots/queries"; import { secretKeys } from "./queries"; -import { TCreateSecretsV3DTO, TDeleteSecretsV3DTO, TUpdateSecretsV3DTO } from "./types"; +import { + CreateSecretDTO, + TCreateSecretBatchDTO, + TCreateSecretsV3DTO, + TDeleteSecretBatchDTO, + TDeleteSecretsV3DTO, + TUpdateSecretBatchDTO, + TUpdateSecretsV3DTO +} from "./types"; const encryptSecret = (randomBytes: string, key: string, value?: string, comment?: string) => { // encrypt key @@ -55,7 +64,11 @@ const encryptSecret = (randomBytes: string, key: string, value?: string, comment }; }; -export const useCreateSecretV3 = () => { +export const useCreateSecretV3 = ({ + options +}: { + options?: Omit, "mutationFn">; +} = {}) => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateSecretsV3DTO>({ mutationFn: async ({ @@ -66,7 +79,8 @@ export const useCreateSecretV3 = () => { secretName, secretValue, latestFileKey, - secretComment + secretComment, + skipMultilineEncoding }) => { const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; @@ -84,20 +98,32 @@ export const useCreateSecretV3 = () => { environment, type, secretPath, - ...encryptSecret(randomBytes, secretName, secretValue, secretComment) + ...encryptSecret(randomBytes, secretName, secretValue, secretComment), + skipMultilineEncoding }; const { data } = await apiRequest.post(`/api/v3/secrets/${secretName}`, reqBody); return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { queryClient.invalidateQueries( - secretKeys.getProjectSecret(workspaceId, environment, secretPath) + secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); - } + queryClient.invalidateQueries( + secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) + ); + }, + ...options }); }; -export const useUpdateSecretV3 = () => { +export const useUpdateSecretV3 = ({ + options +}: { + options?: Omit, "mutationFn">; +} = {}) => { const queryClient = useQueryClient(); return useMutation<{}, {}, TUpdateSecretsV3DTO>({ mutationFn: async ({ @@ -107,7 +133,11 @@ export const useUpdateSecretV3 = () => { workspaceId, secretName, secretValue, - latestFileKey + latestFileKey, + tags, + secretComment, + newSecretName, + skipMultilineEncoding }) => { const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; @@ -119,34 +149,40 @@ export const useUpdateSecretV3 = () => { privateKey: PRIVATE_KEY }) : crypto.randomBytes(16).toString("hex"); - const { secretValueIV, secretValueTag, secretValueCiphertext } = encryptSecret( - randomBytes, - secretName, - secretValue, - "" - ); const reqBody = { workspaceId, environment, type, secretPath, - secretValueIV, - secretValueTag, - secretValueCiphertext + ...encryptSecret(randomBytes, newSecretName ?? secretName, secretValue, secretComment), + tags, + skipMultilineEncoding, + secretName: newSecretName }; const { data } = await apiRequest.patch(`/api/v3/secrets/${secretName}`, reqBody); return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { queryClient.invalidateQueries( - secretKeys.getProjectSecret(workspaceId, environment, secretPath) + secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); - } + queryClient.invalidateQueries( + secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) + ); + }, + ...options }); }; -export const useDeleteSecretV3 = () => { +export const useDeleteSecretV3 = ({ + options +}: { + options?: Omit, "mutationFn">; +} = {}) => { const queryClient = useQueryClient(); return useMutation<{}, {}, TDeleteSecretsV3DTO>({ @@ -165,8 +201,160 @@ export const useDeleteSecretV3 = () => { }, onSuccess: (_, { workspaceId, environment, secretPath }) => { queryClient.invalidateQueries( - secretKeys.getProjectSecret(workspaceId, environment, secretPath) + secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); - } + queryClient.invalidateQueries( + secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) + ); + }, + ...options }); }; + +export const useCreateSecretBatch = ({ + options +}: { + options?: Omit, "mutationFn">; +} = {}) => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateSecretBatchDTO>({ + mutationFn: async ({ secretPath = "/", workspaceId, environment, secrets, latestFileKey }) => { + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; + const randomBytes = latestFileKey + ? decryptAssymmetric({ + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) + : crypto.randomBytes(16).toString("hex"); + + const reqBody = { + workspaceId, + environment, + secretPath, + secrets: secrets.map( + ({ secretName, secretValue, secretComment, metadata, type, skipMultilineEncoding }) => ({ + secretName, + ...encryptSecret(randomBytes, secretName, secretValue, secretComment), + type, + metadata, + skipMultilineEncoding + }) + ) + }; + + const { data } = await apiRequest.post("/api/v3/secrets/batch", reqBody); + return data; + }, + onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) + ); + }, + ...options + }); +}; + +export const useUpdateSecretBatch = ({ + options +}: { + options?: Omit, "mutationFn">; +} = {}) => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSecretBatchDTO>({ + mutationFn: async ({ secretPath = "/", workspaceId, environment, secrets, latestFileKey }) => { + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; + const randomBytes = latestFileKey + ? decryptAssymmetric({ + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) + : crypto.randomBytes(16).toString("hex"); + + const reqBody = { + workspaceId, + environment, + secretPath, + secrets: secrets.map( + ({ secretName, secretValue, secretComment, type, tags, skipMultilineEncoding }) => ({ + secretName, + ...encryptSecret(randomBytes, secretName, secretValue, secretComment), + type, + tags, + skipMultilineEncoding + }) + ) + }; + + const { data } = await apiRequest.patch("/api/v3/secrets/batch", reqBody); + return data; + }, + onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) + ); + }, + ...options + }); +}; + +export const useDeleteSecretBatch = ({ + options +}: { + options?: Omit, "mutationFn">; +} = {}) => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSecretBatchDTO>({ + mutationFn: async ({ secretPath = "/", workspaceId, environment, secrets }) => { + const reqBody = { + workspaceId, + environment, + secretPath, + secrets + }; + + const { data } = await apiRequest.delete("/api/v3/secrets/batch", { + data: reqBody + }); + return data; + }, + onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) + ); + }, + ...options + }); +}; + +export const createSecret = async (dto: CreateSecretDTO) => { + const { data } = await apiRequest.post(`/api/v3/secrets/${dto.secretKey}`, dto); + return data; +}; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index cd032e58b..a9cbc3b29 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ import { useCallback, useMemo } from "react"; -import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueries, useQuery, UseQueryOptions } from "@tanstack/react-query"; import { decryptAssymmetric, @@ -8,218 +8,148 @@ import { } from "@app/components/utilities/cryptography/crypto"; import { apiRequest } from "@app/config/request"; -import { secretSnapshotKeys } from "../secretSnapshots/queries"; +import { UserWsKeyPair } from "../keys/types"; import { - BatchSecretDTO, - CreateSecretDTO, DecryptedSecret, EncryptedSecret, EncryptedSecretVersion, - GetProjectSecretsDTO, GetSecretVersionsDTO, - TGetProjectSecretsAllEnvDTO} from "./types"; + TGetProjectSecretsAllEnvDTO, + TGetProjectSecretsDTO, + TGetProjectSecretsKey +} from "./types"; export const secretKeys = { // this is also used in secretSnapshot part - getProjectSecret: (workspaceId: string, env: string | string[], folderId?: string) => [ - { workspaceId, env, folderId }, - "secrets" - ], - getProjectSecretImports: (workspaceId: string, env: string | string[], folderId?: string) => [ - { workspaceId, env, folderId }, - "secrets-imports" - ], - getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] + getProjectSecret: ({ workspaceId, environment, secretPath }: TGetProjectSecretsKey) => + [{ workspaceId, environment, secretPath }, "secrets"] as const, + getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] as const }; -const fetchProjectEncryptedSecrets = async ( - workspaceId: string, - env: string | string[], - folderId?: string, - secretPath?: string -) => { +const decryptSecrets = (encryptedSecrets: EncryptedSecret[], decryptFileKey: UserWsKeyPair) => { + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; + const key = decryptAssymmetric({ + ciphertext: decryptFileKey.encryptedKey, + nonce: decryptFileKey.nonce, + publicKey: decryptFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const personalSecrets: Record = {}; + const secrets: DecryptedSecret[] = []; + encryptedSecrets.forEach((encSecret) => { + const secretKey = decryptSymmetric({ + ciphertext: encSecret.secretKeyCiphertext, + iv: encSecret.secretKeyIV, + tag: encSecret.secretKeyTag, + key + }); + + const secretValue = decryptSymmetric({ + ciphertext: encSecret.secretValueCiphertext, + iv: encSecret.secretValueIV, + tag: encSecret.secretValueTag, + key + }); + + const secretComment = decryptSymmetric({ + ciphertext: encSecret.secretCommentCiphertext, + iv: encSecret.secretCommentIV, + tag: encSecret.secretCommentTag, + key + }); + + const decryptedSecret: DecryptedSecret = { + _id: encSecret._id, + env: encSecret.environment, + key: secretKey, + value: secretValue, + tags: encSecret.tags, + comment: secretComment, + createdAt: encSecret.createdAt, + updatedAt: encSecret.updatedAt, + version: encSecret.version, + skipMultilineEncoding: encSecret.skipMultilineEncoding + }; + + if (encSecret.type === "personal") { + personalSecrets[decryptedSecret.key] = { + id: encSecret._id, + value: secretValue + }; + } else { + secrets.push(decryptedSecret); + } + }); + + secrets.forEach((sec) => { + if (personalSecrets?.[sec.key]) { + sec.idOverride = personalSecrets[sec.key].id; + sec.valueOverride = personalSecrets[sec.key].value; + sec.overrideAction = "modified"; + } + }); + + return secrets; +}; + +const fetchProjectEncryptedSecrets = async ({ + workspaceId, + environment, + secretPath +}: TGetProjectSecretsKey) => { const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>("/api/v3/secrets", { params: { - environment: env, + environment, workspaceId, - folderId: folderId || undefined, secretPath } }); - + return data.secrets; }; - export const useGetProjectSecrets = ({ workspaceId, - env, + environment, decryptFileKey, - isPaused, - folderId, - secretPath -}: GetProjectSecretsDTO) => + secretPath, + options +}: TGetProjectSecretsDTO & { + options?: Omit< + UseQueryOptions< + EncryptedSecret[], + unknown, + DecryptedSecret[], + ReturnType + >, + "queryKey" | "queryFn" + >; +}) => useQuery({ + ...options, // wait for all values to be available - enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused, - queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId || secretPath), - queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath), - select: useCallback( - (data: EncryptedSecret[]) => { - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; - const latestKey = decryptFileKey; - const key = decryptAssymmetric({ - ciphertext: latestKey.encryptedKey, - nonce: latestKey.nonce, - publicKey: latestKey.sender.publicKey, - privateKey: PRIVATE_KEY - }); - - const sharedSecrets: DecryptedSecret[] = []; - const personalSecrets: Record = {}; - // this used for add-only mode in dashboard - // type won't be there thus only one key is shown - const duplicateSecretKey: Record = {}; - data.forEach((encSecret: EncryptedSecret) => { - const secretKey = decryptSymmetric({ - ciphertext: encSecret.secretKeyCiphertext, - iv: encSecret.secretKeyIV, - tag: encSecret.secretKeyTag, - key - }); - - const secretValue = decryptSymmetric({ - ciphertext: encSecret.secretValueCiphertext, - iv: encSecret.secretValueIV, - tag: encSecret.secretValueTag, - key - }); - - const secretComment = decryptSymmetric({ - ciphertext: encSecret.secretCommentCiphertext, - iv: encSecret.secretCommentIV, - tag: encSecret.secretCommentTag, - key - }); - - const decryptedSecret = { - _id: encSecret._id, - env: encSecret.environment, - key: secretKey, - value: secretValue, - tags: encSecret.tags, - comment: secretComment, - createdAt: encSecret.createdAt, - updatedAt: encSecret.updatedAt - }; - - if (encSecret.type === "personal") { - personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = { - id: encSecret._id, - value: secretValue - }; - } else { - if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) { - sharedSecrets.push(decryptedSecret); - } - duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true; - } - }); - sharedSecrets.forEach((val) => { - const dupKey = `${val.key}-${val.env}`; - if (personalSecrets?.[dupKey]) { - val.idOverride = personalSecrets[dupKey].id; - val.valueOverride = personalSecrets[dupKey].value; - val.overrideAction = "modified"; - } - }); - return { secrets: sharedSecrets }; - }, - [decryptFileKey] - ) + enabled: Boolean(decryptFileKey && workspaceId && environment) && (options?.enabled ?? true), + queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }), + queryFn: async () => fetchProjectEncryptedSecrets({ workspaceId, environment, secretPath }), + select: (secrets: EncryptedSecret[]) => decryptSecrets(secrets, decryptFileKey) }); export const useGetProjectSecretsAllEnv = ({ workspaceId, envs, decryptFileKey, - folderId, secretPath }: TGetProjectSecretsAllEnvDTO) => { const secrets = useQueries({ - queries: envs.map((env) => ({ - queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath || folderId), - enabled: Boolean(decryptFileKey && workspaceId && env), - queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath), - select: (data: EncryptedSecret[]) => { - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; - const latestKey = decryptFileKey; - const key = decryptAssymmetric({ - ciphertext: latestKey.encryptedKey, - nonce: latestKey.nonce, - publicKey: latestKey.sender.publicKey, - privateKey: PRIVATE_KEY - }); - - const sharedSecrets: Record = {}; - const personalSecrets: Record = {}; - // this used for add-only mode in dashboard - // type won't be there thus only one key is shown - const duplicateSecretKey: Record = {}; - data.forEach((encSecret: EncryptedSecret) => { - const secretKey = decryptSymmetric({ - ciphertext: encSecret.secretKeyCiphertext, - iv: encSecret.secretKeyIV, - tag: encSecret.secretKeyTag, - key - }); - - const secretValue = decryptSymmetric({ - ciphertext: encSecret.secretValueCiphertext, - iv: encSecret.secretValueIV, - tag: encSecret.secretValueTag, - key - }); - - const secretComment = decryptSymmetric({ - ciphertext: encSecret.secretCommentCiphertext, - iv: encSecret.secretCommentIV, - tag: encSecret.secretCommentTag, - key - }); - - const decryptedSecret = { - _id: encSecret._id, - env: encSecret.environment, - key: secretKey, - value: secretValue, - tags: encSecret.tags, - comment: secretComment, - createdAt: encSecret.createdAt, - updatedAt: encSecret.updatedAt - }; - - if (encSecret.type === "personal") { - personalSecrets[decryptedSecret.key] = { - id: encSecret._id, - value: secretValue - }; - } else { - if (!duplicateSecretKey?.[decryptedSecret.key]) { - sharedSecrets[decryptedSecret.key] = decryptedSecret; - } - duplicateSecretKey[decryptedSecret.key] = true; - } - }); - - Object.keys(sharedSecrets).forEach((val) => { - if (personalSecrets?.[val]) { - sharedSecrets[val].idOverride = personalSecrets[val].id; - sharedSecrets[val].valueOverride = personalSecrets[val].value; - sharedSecrets[val].overrideAction = "modified"; - } - }); - return sharedSecrets; - } + queries: envs.map((environment) => ({ + queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }), + enabled: Boolean(decryptFileKey && workspaceId && environment), + queryFn: async () => fetchProjectEncryptedSecrets({ workspaceId, environment, secretPath }), + select: (secs: EncryptedSecret[]) => + decryptSecrets(secs, decryptFileKey).reduce>( + (prev, curr) => ({ ...prev, [curr.key]: curr }), + {} + ) })) }); @@ -303,46 +233,3 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) => [dto.decryptFileKey] ) }); - -export const useBatchSecretsOp = () => { - const queryClient = useQueryClient(); - - return useMutation<{}, {}, BatchSecretDTO>({ - mutationFn: async (dto) => { - const { data } = await apiRequest.post("/api/v2/secrets/batch", dto); - return data; - }, - onSuccess: (_, dto) => { - queryClient.invalidateQueries( - secretKeys.getProjectSecret(dto.workspaceId, dto.environment, dto.folderId) - ); - queryClient.invalidateQueries( - secretSnapshotKeys.list(dto.workspaceId, dto.environment, dto?.folderId) - ); - queryClient.invalidateQueries( - secretSnapshotKeys.count(dto.workspaceId, dto.environment, dto?.folderId) - ); - } - }); -}; - -export const createSecret = async (dto: CreateSecretDTO) => { - const { data } = await apiRequest.post(`/api/v3/secrets/${dto.secretKey}`, dto); - return data; -} - -export const useCreateSecret = () => { - const queryClient = useQueryClient(); - - return useMutation<{}, {}, CreateSecretDTO>({ - mutationFn: async (dto) => { - const data = createSecret(dto); - return data; - }, - onSuccess: (_, dto) => { - queryClient.invalidateQueries( - secretKeys.getProjectSecret(dto.workspaceId, dto.environment) - ); - } - }); -}; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 2bf304f4e..253ce77a6 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -16,6 +16,7 @@ export type EncryptedSecret = { __v: number; createdAt: string; updatedAt: string; + skipMultilineEncoding?: boolean; secretCommentCiphertext: string; secretCommentIV: string; secretCommentTag: string; @@ -24,6 +25,7 @@ export type EncryptedSecret = { export type DecryptedSecret = { _id: string; + version: number; key: string; value: string; comment: string; @@ -35,6 +37,7 @@ export type DecryptedSecret = { idOverride?: string; overrideAction?: string; folderId?: string; + skipMultilineEncoding?: boolean; }; export type EncryptedSecretVersion = { @@ -53,55 +56,21 @@ export type EncryptedSecretVersion = { secretValueTag: string; tags: WsTag[]; __v: number; + skipMultilineEncoding?: boolean; createdAt: string; updatedAt: string; }; // dto -type SecretTagArg = { _id: string; name: string; slug: string }; - -export type UpdateSecretArg = { - _id: string; - folderId?: string; - type: "shared" | "personal"; - secretName: string; - secretKeyCiphertext: string; - secretKeyIV: string; - secretKeyTag: string; - secretValueCiphertext: string; - secretValueIV: string; - secretValueTag: string; - secretCommentCiphertext: string; - secretCommentIV: string; - secretCommentTag: string; - tags: SecretTagArg[]; -}; - -export type CreateSecretArg = Omit; - -export type DeleteSecretArg = { _id: string, secretName: string; }; - -export type BatchSecretDTO = { +export type TGetProjectSecretsKey = { workspaceId: string; - folderId: string; environment: string; - requests: Array< - | { method: "POST"; secret: CreateSecretArg } - | { method: "PATCH"; secret: UpdateSecretArg } - | { method: "DELETE"; secret: DeleteSecretArg } - >; + secretPath?: string; }; -export type GetProjectSecretsDTO = { - workspaceId: string; - env: string | string[]; +export type TGetProjectSecretsDTO = { decryptFileKey: UserWsKeyPair; - folderId?: string; - secretPath?: string; - isPaused?: boolean; - include_imports?: boolean; - onSuccess?: (data: DecryptedSecret[]) => void; -}; +} & TGetProjectSecretsKey; export type TGetProjectSecretsAllEnvDTO = { workspaceId: string; @@ -124,6 +93,7 @@ export type TCreateSecretsV3DTO = { secretName: string; secretValue: string; secretComment: string; + skipMultilineEncoding?: boolean; secretPath: string; workspaceId: string; environment: string; @@ -136,19 +106,63 @@ export type TUpdateSecretsV3DTO = { environment: string; type: string; secretPath: string; + skipMultilineEncoding?: boolean; + newSecretName?: string; secretName: string; secretValue: string; + secretComment?: string; + tags?: string[]; }; export type TDeleteSecretsV3DTO = { workspaceId: string; environment: string; - type: string; + type: "shared" | "personal"; secretPath: string; secretName: string; }; -// --- v3 +export type TCreateSecretBatchDTO = { + workspaceId: string; + environment: string; + secretPath: string; + latestFileKey: UserWsKeyPair; + secrets: Array<{ + secretName: string; + secretValue: string; + secretComment: string; + skipMultilineEncoding?: boolean; + type: "shared" | "personal"; + metadata?: { + source?: string; + }; + }>; +}; + +export type TUpdateSecretBatchDTO = { + workspaceId: string; + environment: string; + secretPath: string; + latestFileKey: UserWsKeyPair; + secrets: Array<{ + type: "shared" | "personal"; + secretName: string; + skipMultilineEncoding?: boolean; + secretValue: string; + secretComment: string; + tags?: string[]; + }>; +}; + +export type TDeleteSecretBatchDTO = { + workspaceId: string; + environment: string; + secretPath: string; + secrets: Array<{ + secretName: string; + type: "shared" | "personal"; + }>; +}; export type CreateSecretDTO = { workspaceId: string; @@ -167,5 +181,5 @@ export type CreateSecretDTO = { secretPath: string; metadata?: { source?: string; - } -} \ No newline at end of file + }; +}; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index c91fb551d..b46b5bfab 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -5,6 +5,9 @@ export type { TCloudIntegration, TIntegration } from "./integrations/types"; export type { UserWsKeyPair } from "./keys/types"; export type { Organization } from "./organization/types"; export type { TSecretApprovalPolicy } from "./secretApproval/types"; +export type { TSecretFolder } from "./secretFolders/types"; +export type { TImportedSecrets, TSecretImports } from "./secretImports/types"; +export * from "./secrets/types"; export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types"; export type { SubscriptionPlan } from "./subscriptions/types"; export type { WsTag } from "./tags/types"; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 39098bf5c..0dd859ed4 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -315,7 +315,7 @@ module.exports = { // TODO:(akhilmhdh) remove all these unused and keep the config file as small as possible // Make the whole color pallelte into simpler bounce: "bounce 1000ms ease-in-out infinite", - spin: "spin 4000ms ease-in-out infinite", + spin: "spin 1500ms ease-in-out infinite", cursor: "cursor .6s linear infinite alternate", type: "type 2.7s ease-out .8s infinite alternate both", "type-reverse": "type 1.8s ease-out 0s infinite alternate-reverse both", From b54c29fc4862857fbe3ea93058d1f56a4d634faf Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 22 Sep 2023 21:50:28 +0530 Subject: [PATCH 17/30] feat(dashboard-v3): implemented new the dashboard with v3 support --- frontend/package-lock.json | 24 + frontend/package.json | 2 + .../src/pages/project/[id]/secrets/[env].tsx | 4 +- .../pages/project/[id]/secrets/v2/[env].tsx | 27 + .../src/views/DashboardPage/DashboardPage.tsx | 1263 ----------------- .../DashboardPage/DashboardPage.utils.ts | 289 ---- .../CompareSecret/CompareSecret.tsx | 64 - .../components/CompareSecret/index.tsx | 1 - .../CreateTagModal/CreateTagModal.tsx | 191 --- .../components/CreateTagModal/index.tsx | 1 - .../FolderSection/FolderSection.tsx | 108 -- .../components/FolderSection/index.tsx | 3 - .../components/FolderSection/types.ts | 2 - .../SecretDetailDrawer/SecretDetailDrawer.tsx | 237 ---- .../components/SecretDetailDrawer/index.tsx | 1 - .../SecretDropzone/SecretDropzone.tsx | 451 ------ .../SecretImportForm/SecretImportForm.tsx | 86 -- .../components/SecretImportForm/index.tsx | 1 - .../SecretImportSection/SecretImportItem.tsx | 200 --- .../SecretImportSection.tsx | 107 -- .../components/SecretImportSection/index.tsx | 1 - .../SecretInputRow/SecretInputRow.tsx | 525 ------- .../components/SecretInputRow/index.tsx | 1 - .../SecretTableHeader/SecretTableHeader.tsx | 36 - .../components/SecretTableHeader/index.tsx | 1 - frontend/src/views/DashboardPage/index.tsx | 1 - .../views/SecretMainPage/SecretMainPage.tsx | 358 +++++ .../SecretMainPage/SecretMainPage.types.ts | 14 + .../components/ActionBar/ActionBar.tsx | 454 ++++++ .../components/ActionBar/CreateSecretForm.tsx | 126 ++ .../ActionBar/CreateSecretImportForm.tsx | 152 ++ .../components/ActionBar}/FolderForm.tsx | 26 +- .../components/ActionBar/index.tsx | 1 + .../FolderListView/FolderListView.tsx | 188 +++ .../components/FolderListView/index.tsx | 1 + .../components/PitDrawer/PitDrawer.tsx | 4 +- .../components/PitDrawer/index.tsx | 0 .../SecretDropzone/CopySecretsFromBoard.tsx | 284 ++++ .../SecretDropzone/SecretDropzone.tsx | 338 +++++ .../components/SecretDropzone/index.tsx | 0 .../SecretImportListView/SecretImportItem.tsx | 190 +++ .../SecretImportListView.tsx | 218 +++ .../components/SecretImportListView/index.tsx | 1 + .../SecretListView}/GenRandomNumber.tsx | 0 .../SecretListView/SecretDetaiSidebar.tsx | 389 +++++ .../components/SecretListView/SecretItem.tsx | 481 +++++++ .../SecretListView/SecretListView.tsx | 334 +++++ .../SecretListView/SecretListView.utils.ts | 28 + .../components/SecretListView/index.tsx | 1 + .../components/SnapshotView/SecretItem.tsx | 190 +++ .../components/SnapshotView/SnapshotView.tsx | 268 ++++ .../components/SnapshotView/index.tsx | 1 + frontend/src/views/SecretMainPage/index.tsx | 1 + .../SecretOverviewPage/SecretOverviewPage.tsx | 11 +- .../SecretOverviewTableRow/SecretEditRow.tsx | 4 +- 55 files changed, 4091 insertions(+), 3599 deletions(-) create mode 100644 frontend/src/pages/project/[id]/secrets/v2/[env].tsx delete mode 100644 frontend/src/views/DashboardPage/DashboardPage.tsx delete mode 100644 frontend/src/views/DashboardPage/DashboardPage.utils.ts delete mode 100644 frontend/src/views/DashboardPage/components/CompareSecret/CompareSecret.tsx delete mode 100644 frontend/src/views/DashboardPage/components/CompareSecret/index.tsx delete mode 100644 frontend/src/views/DashboardPage/components/CreateTagModal/CreateTagModal.tsx delete mode 100644 frontend/src/views/DashboardPage/components/CreateTagModal/index.tsx delete mode 100644 frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx delete mode 100644 frontend/src/views/DashboardPage/components/FolderSection/index.tsx delete mode 100644 frontend/src/views/DashboardPage/components/FolderSection/types.ts delete mode 100644 frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx delete mode 100644 frontend/src/views/DashboardPage/components/SecretDetailDrawer/index.tsx delete mode 100644 frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx delete mode 100644 frontend/src/views/DashboardPage/components/SecretImportForm/SecretImportForm.tsx delete mode 100644 frontend/src/views/DashboardPage/components/SecretImportForm/index.tsx delete mode 100644 frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx delete mode 100644 frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx delete mode 100644 frontend/src/views/DashboardPage/components/SecretImportSection/index.tsx delete mode 100644 frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx delete mode 100644 frontend/src/views/DashboardPage/components/SecretInputRow/index.tsx delete mode 100644 frontend/src/views/DashboardPage/components/SecretTableHeader/SecretTableHeader.tsx delete mode 100644 frontend/src/views/DashboardPage/components/SecretTableHeader/index.tsx delete mode 100644 frontend/src/views/DashboardPage/index.tsx create mode 100644 frontend/src/views/SecretMainPage/SecretMainPage.tsx create mode 100644 frontend/src/views/SecretMainPage/SecretMainPage.types.ts create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretForm.tsx create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx rename frontend/src/views/{DashboardPage/components/FolderSection => SecretMainPage/components/ActionBar}/FolderForm.tsx (72%) create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/index.tsx create mode 100644 frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx create mode 100644 frontend/src/views/SecretMainPage/components/FolderListView/index.tsx rename frontend/src/views/{DashboardPage => SecretMainPage}/components/PitDrawer/PitDrawer.tsx (94%) rename frontend/src/views/{DashboardPage => SecretMainPage}/components/PitDrawer/index.tsx (100%) create mode 100644 frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx create mode 100644 frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx rename frontend/src/views/{DashboardPage => SecretMainPage}/components/SecretDropzone/index.tsx (100%) create mode 100644 frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx create mode 100644 frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx create mode 100644 frontend/src/views/SecretMainPage/components/SecretImportListView/index.tsx rename frontend/src/views/{DashboardPage/components/SecretDetailDrawer => SecretMainPage/components/SecretListView}/GenRandomNumber.tsx (100%) create mode 100644 frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx create mode 100644 frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx create mode 100644 frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx create mode 100644 frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts create mode 100644 frontend/src/views/SecretMainPage/components/SecretListView/index.tsx create mode 100644 frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx create mode 100644 frontend/src/views/SecretMainPage/components/SnapshotView/SnapshotView.tsx create mode 100644 frontend/src/views/SecretMainPage/components/SnapshotView/index.tsx create mode 100644 frontend/src/views/SecretMainPage/index.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 28ea1859e..f34d3fbab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -51,6 +51,7 @@ "cookies": "^0.8.0", "cva": "npm:class-variance-authority@^0.4.0", "date-fns": "^2.30.0", + "file-saver": "^2.0.5", "framer-motion": "^6.2.3", "fs": "^0.0.2", "gray-matter": "^4.0.3", @@ -105,6 +106,7 @@ "@storybook/react": "^7.0.23", "@storybook/testing-library": "^0.2.0", "@tailwindcss/typography": "^0.5.4", + "@types/file-saver": "^2.0.5", "@types/jsrp": "^0.2.4", "@types/node": "^18.11.9", "@types/picomatch": "^2.3.0", @@ -8208,6 +8210,12 @@ "@types/send": "*" } }, + "node_modules/@types/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==", + "dev": true + }, "node_modules/@types/find-cache-dir": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz", @@ -13444,6 +13452,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/file-system-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-2.3.0.tgz", @@ -29227,6 +29240,12 @@ "@types/send": "*" } }, + "@types/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==", + "dev": true + }, "@types/find-cache-dir": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz", @@ -33358,6 +33377,11 @@ "flat-cache": "^3.0.4" } }, + "file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "file-system-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-2.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 42cd32385..0ff0c89eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,6 +59,7 @@ "cookies": "^0.8.0", "cva": "npm:class-variance-authority@^0.4.0", "date-fns": "^2.30.0", + "file-saver": "^2.0.5", "framer-motion": "^6.2.3", "fs": "^0.0.2", "gray-matter": "^4.0.3", @@ -113,6 +114,7 @@ "@storybook/react": "^7.0.23", "@storybook/testing-library": "^0.2.0", "@tailwindcss/typography": "^0.5.4", + "@types/file-saver": "^2.0.5", "@types/jsrp": "^0.2.4", "@types/node": "^18.11.9", "@types/picomatch": "^2.3.0", diff --git a/frontend/src/pages/project/[id]/secrets/[env].tsx b/frontend/src/pages/project/[id]/secrets/[env].tsx index 883850592..bba0704dd 100644 --- a/frontend/src/pages/project/[id]/secrets/[env].tsx +++ b/frontend/src/pages/project/[id]/secrets/[env].tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import Head from "next/head"; -import { DashboardPage } from "@app/views/DashboardPage"; +import { SecretMainPage } from "@app/views/SecretMainPage"; const Dashboard = () => { const { t } = useTranslation(); @@ -16,7 +16,7 @@ const Dashboard = () => {
    - +
    ); diff --git a/frontend/src/pages/project/[id]/secrets/v2/[env].tsx b/frontend/src/pages/project/[id]/secrets/v2/[env].tsx new file mode 100644 index 000000000..bba0704dd --- /dev/null +++ b/frontend/src/pages/project/[id]/secrets/v2/[env].tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { SecretMainPage } from "@app/views/SecretMainPage"; + +const Dashboard = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("dashboard.title") })} + + + + + +
    + +
    + + ); +}; + +export default Dashboard; + +Dashboard.requireAuth = true; diff --git a/frontend/src/views/DashboardPage/DashboardPage.tsx b/frontend/src/views/DashboardPage/DashboardPage.tsx deleted file mode 100644 index 220ddb146..000000000 --- a/frontend/src/views/DashboardPage/DashboardPage.tsx +++ /dev/null @@ -1,1263 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { FormProvider, useFieldArray, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { useRouter } from "next/router"; -import { subject } from "@casl/ability"; -import { - closestCenter, - DndContext, - DragEndEvent, - KeyboardSensor, - MouseSensor, - TouchSensor, - useSensor, - useSensors -} from "@dnd-kit/core"; -import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { arrayMove } from "@dnd-kit/sortable"; -import { - faAngleDown, - faArrowLeft, - faCheck, - faClockRotateLeft, - faCodeCommit, - faDownload, - faEye, - faEyeSlash, - faFileImport, - faFolderPlus, - faMagnifyingGlass, - faPlus -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger -} from "@radix-ui/react-dropdown-menu"; -import { useQueryClient } from "@tanstack/react-query"; - -import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import NavHeader from "@app/components/navigation/NavHeader"; -import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions"; -import { - Button, - DeleteActionModal, - IconButton, - Input, - Modal, - ModalContent, - Popover, - PopoverContent, - PopoverTrigger, - TableContainer, - Tag, - Tooltip, - UpgradePlanModal -} from "@app/components/v2"; -import { leaveConfirmDefaultMessage } from "@app/const"; -import { - ProjectPermissionActions, - ProjectPermissionSub, - useOrganization, - useProjectPermission, - useSubscription, - useWorkspace -} from "@app/context"; -import { useLeaveConfirm, usePopUp, useToggle } from "@app/hooks"; -import { - useBatchSecretsOp, - useCreateFolder, - useCreateSecretImport, - useCreateWsTag, - useDeleteFolder, - useDeleteSecretImport, - useGetImportedSecrets, - useGetProjectFolders, - useGetProjectSecrets, - useGetSecretImports, - useGetSecretVersion, - useGetSnapshotSecrets, - useGetUserAction, - useGetUserWsKey, - useGetWorkspaceSecretSnapshots, - useGetWsSnapshotCount, - useGetWsTags, - usePerformSecretRollback, - useRegisterUserAction, - useUpdateFolder, - useUpdateSecretImport -} from "@app/hooks/api"; -import { secretKeys } from "@app/hooks/api/secrets/queries"; - -import { CompareSecret } from "./components/CompareSecret"; -import { CreateTagModal } from "./components/CreateTagModal"; -import { - FolderForm, - FolderSection, - TDeleteFolderForm, - TEditFolderForm -} from "./components/FolderSection"; -import { PitDrawer } from "./components/PitDrawer"; -import { SecretDetailDrawer } from "./components/SecretDetailDrawer"; -import { SecretDropzone } from "./components/SecretDropzone"; -import { SecretImportForm } from "./components/SecretImportForm"; -import { SecretImportSection } from "./components/SecretImportSection"; -import { SecretInputRow } from "./components/SecretInputRow"; -import { SecretTableHeader } from "./components/SecretTableHeader"; -import { - DEFAULT_SECRET_VALUE, - downloadSecret, - FormData, - schema, - transformSecretsToBatchSecretReq, - TSecOverwriteOpt, - TSecretDetailsOpen -} from "./DashboardPage.utils"; - -const USER_ACTION_PUSH = "first_time_secrets_pushed"; -type TDeleteSecretImport = { environment: string; secretPath: string }; -/* - * Some imp aspects to consider. Here there are multiple stats changing - * Thus ideally we need to use a context. But instead we rely on react hook form - * React hook form provides context and high performance proxy based rendering - * It also handles error handling and transferring states between inputs - * - * Another thing is the purpose of overrideAction - * Before we would remove the value for personal secret when user toggle and user couldn't get it back - * They have to reload the browser or go back all over again - * Instead when user delete we raise a flag so if user decides to go back to toggle personal before saving - * They will get it back - */ -export const DashboardPage = () => { - const { subscription } = useSubscription(); - const { t } = useTranslation(); - const router = useRouter(); - const { createNotification } = useNotificationContext(); - const queryClient = useQueryClient(); - const environment = router.query.env as string; - const permission = useProjectPermission(); - - const secretContainer = useRef(null); - const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ - "secretDetails", - "addTag", - "secretSnapshots", - "uploadedSecOpts", - "compareSecrets", - "folderForm", - "deleteFolder", - "upgradePlan", - "addSecretImport", - "deleteSecretImport" - ] as const); - const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true); - const [searchFilter, setSearchFilter] = useState(""); - const [snapshotId, setSnaphotId] = useState(null); - const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); - const deletedSecretIds = useRef<{ id: string; secretName: string }[]>([]); - const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false }); - - const folderId = router.query.folderId as string; - const isRollbackMode = Boolean(snapshotId); - - const { currentWorkspace, isLoading } = useWorkspace(); - const { currentOrg } = useOrganization(); - const workspaceId = currentWorkspace?._id as string; - - const { data: latestFileKey } = useGetUserWsKey(workspaceId); - - useEffect(() => { - if (!isLoading && !workspaceId && router.isReady) { - router.push(`/org/${currentOrg?._id}/overview`); - } - }, [isLoading, workspaceId, router.isReady]); - - // fetching data - const { data: userAction } = useGetUserAction(USER_ACTION_PUSH); - const hasUserPushed = Boolean(userAction); - - const { data: secretVersion } = useGetSecretVersion({ - limit: 10, - offset: 0, - secretId: (popUp?.secretDetails?.data as TSecretDetailsOpen)?.id, - decryptFileKey: latestFileKey! - }); - - const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ - workspaceId, - env: environment, - decryptFileKey: latestFileKey!, - isPaused: Boolean(snapshotId), - folderId - }); - - const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({ - workspaceId: workspaceId || "", - environment, - parentFolderId: folderId, - isPaused: isRollbackMode, - sortDir - }); - - const { - data: secretSnaphots, - fetchNextPage, - hasNextPage, - isFetchingNextPage - } = useGetWorkspaceSecretSnapshots({ - workspaceId, - environment, - folder: folderId, - limit: 10 - }); - - const { - data: snapshotSecret, - isLoading: isSnapshotSecretsLoading, - isFetching: isSnapshotChanging - } = useGetSnapshotSecrets({ - snapshotId: snapshotId || "", - env: environment, - decryptFileKey: latestFileKey! - }); - - const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount( - workspaceId, - environment, - folderId - ); - - const { data: wsTags } = useGetWsTags(workspaceId); - - // mutation calls - const { mutateAsync: batchSecretOp } = useBatchSecretsOp(); - const { mutateAsync: performSecretRollback } = usePerformSecretRollback(); - const { mutateAsync: registerUserAction } = useRegisterUserAction(); - const { mutateAsync: createWsTag } = useCreateWsTag(); - const { mutateAsync: createFolder } = useCreateFolder(); - const { mutateAsync: updateFolder } = useUpdateFolder(folderId); - const { mutateAsync: deleteFolder } = useDeleteFolder(folderId); - - const { data: secretImportCfg, isFetching: isSecretImportCfgFetching } = useGetSecretImports( - workspaceId, - environment, - folderId - ); - - const { data: importedSecrets } = useGetImportedSecrets({ - workspaceId, - decryptFileKey: latestFileKey!, - environment, - folderId - }); - - const secretPath = `/${(folderData?.dir || []) - ?.filter(({ name }) => name !== "root") - ?.map(({ name }) => name) - .join("/")}`; - - const userAvailableEnvs = currentWorkspace?.environments || []; - - // This is for dnd-kit. As react-query state mutation async - // This will act as a placeholder to avoid a glitching animation on dropping items - const [items, setItems] = useState< - Array<{ environment: string; secretPath: string; id: string }> - >([]); - - useEffect(() => { - if ( - !isSecretImportCfgFetching || - // case in which u go to a folder and come back to fill in with cache data - (items.length === 0 && secretImportCfg?.imports?.length !== 0 && isSecretImportCfgFetching) - ) { - setItems( - secretImportCfg?.imports?.map((el) => ({ - ...el, - id: `${el.environment}-${el.secretPath}` - })) || [] - ); - } - }, [isSecretImportCfgFetching]); - - const { mutateAsync: createSecretImport } = useCreateSecretImport(); - const { mutate: updateSecretImportSync } = useUpdateSecretImport(); - const { mutateAsync: deleteSecretImport } = useDeleteSecretImport(); - - const sensors = useSensors( - useSensor(MouseSensor, {}), - useSensor(TouchSensor, {}), - useSensor(KeyboardSensor, {}) - ); - - const method = useForm({ - // why any: well yup inferred ts expects other keys to defined as undefined - defaultValues: secrets as any, - values: secrets as any, - mode: "onBlur", - resolver: yupResolver(schema) - }); - - const { - register, - control, - handleSubmit, - getValues, - setValue, - formState: { isSubmitting, isDirty, errors }, - reset - } = method; - const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" }); - - const isReadOnly = isFoldersLoading - ? true - : permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ) && - permission.cannot( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ) && - permission.cannot( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ) && - permission.cannot( - ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - - const canDoRollback = !isReadOnly; - const isSubmitDisabled = - isReadOnly || - // not in rollback mode and no form has changed - (!isRollbackMode && !isDirty) || - // in rollback mode and don't have permission to do it - (isRollbackMode && - permission.cannot(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback)) || - isSubmitting; - - useEffect(() => { - if (!isSnapshotChanging && Boolean(snapshotId)) { - reset({ secrets: snapshotSecret?.secrets, isSnapshotMode: true }); - } - }, [isSnapshotChanging]); - - useEffect(() => { - setHasUnsavedChanges(!isSubmitDisabled); - }, [isSubmitDisabled]); - - const onSortSecrets = () => { - const dir = sortDir === "asc" ? "desc" : "asc"; - const sec = getValues("secrets") || []; - const sortedSec = sec.sort((a, b) => - dir === "asc" ? a?.key?.localeCompare(b?.key || "") : b?.key?.localeCompare(a?.key || "") - ); - setValue("secrets", sortedSec); - setSortDir(dir); - }; - - const handleUploadedEnv = (uploadedSec: TSecOverwriteOpt["secrets"]) => { - const sec = getValues("secrets") || []; - const conflictingSec = sec.filter(({ key }) => Boolean(uploadedSec?.[key])); - const conflictingSecIds = conflictingSec.reduce>( - (prev, curr) => ({ - ...prev, - [curr.key]: true - }), - {} - ); - // filter to get all conflicting ones - const conflictingUploadedSec = { ...uploadedSec }; - // append non conflicting ones - Object.keys(uploadedSec).forEach((key) => { - if (!conflictingSecIds?.[key]) { - delete conflictingUploadedSec[key]; - sec.push({ - ...DEFAULT_SECRET_VALUE, - key, - value: uploadedSec[key].value, - comment: uploadedSec[key].comments.join(",") - }); - } - }); - setValue("secrets", sec, { shouldDirty: true }); - if (conflictingSec.length > 0) { - handlePopUpOpen("uploadedSecOpts", { secrets: conflictingUploadedSec }); - } - }; - - const onOverwriteSecrets = () => { - const sec = getValues("secrets") || []; - const uploadedSec = (popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets; - const data: Array<{ key: string; index: number }> = []; - sec.forEach(({ key }, index) => { - if (uploadedSec?.[key]) data.push({ key, index }); - }); - data.forEach(({ key, index }) => { - const { value, comments } = uploadedSec[key]; - const comment = comments.join(", "); - sec[index] = { - ...DEFAULT_SECRET_VALUE, - key, - value, - comment, - tags: sec[index].tags - }; - }); - setValue("secrets", sec, { shouldDirty: true }); - handlePopUpClose("uploadedSecOpts"); - }; - - const onSecretRollback = async () => { - if (!snapshotSecret?.version) { - createNotification({ - text: "Failed to find secret version", - type: "success" - }); - return; - } - try { - await performSecretRollback({ - workspaceId, - version: snapshotSecret.version, - environment, - folderId - }); - setValue("isSnapshotMode", false); - setSnaphotId(null); - queryClient.invalidateQueries(secretKeys.getProjectSecret(workspaceId, environment)); - createNotification({ - text: "Successfully rollback secrets", - type: "success" - }); - } catch (error) { - console.log(error); - createNotification({ - text: "Failed to rollback secrets", - type: "error" - }); - } - }; - - const onAppendSecret = () => { - setSearchFilter(""); - append(DEFAULT_SECRET_VALUE); - }; - - const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => { - if (isSnapshotMode) { - await onSecretRollback(); - return; - } - // just closing this if save is triggered from drawer - handlePopUpClose("secretDetails"); - // encrypt and format the secrets to batch api format - // requests = [ {method:"", secret:""} ] - const batchedSecret = transformSecretsToBatchSecretReq( - deletedSecretIds.current, - latestFileKey, - userSec, - secrets?.secrets - ); - // type check - if (batchedSecret.length === 0) { - reset(); - return; - } - try { - await batchSecretOp({ - requests: batchedSecret, - workspaceId, - folderId, - environment - }); - createNotification({ - text: "Successfully saved changes", - type: "success" - }); - deletedSecretIds.current = []; - if (!hasUserPushed) { - await registerUserAction(USER_ACTION_PUSH); - } - } catch (error) { - console.log(error); - createNotification({ - text: "Failed to save changes", - type: "error" - }); - } - }; - - const onDrawerOpen = useCallback((id: string | undefined, index: number) => { - handlePopUpOpen("secretDetails", { id, index } as TSecretDetailsOpen); - }, []); - - const onEnvChange = (slug: string) => { - if (hasUnsavedChanges) { - // eslint-disable-next-line no-alert - if (!window.confirm(leaveConfirmDefaultMessage)) return; - } - - const query: Record = { ...router.query, env: slug }; - delete query.folderId; - router.push({ - pathname: router.pathname, - query - }); - }; - - const handleDownloadSecret = () => { - const secretsFromImport: { key: string; value: string; comment: string }[] = []; - importedSecrets?.forEach(({ secrets: impSec }) => { - impSec.forEach((el) => { - secretsFromImport.push({ key: el.key, value: el.value, comment: el.comment }); - }); - }); - downloadSecret(getValues("secrets"), secretsFromImport, environment); - }; - - // record all deleted ids - // This will make final deletion easier - const onSecretDelete = useCallback( - (index: number, secretName: string, id?: string, overrideId?: string) => { - if (id) - deletedSecretIds.current.push({ - id, - secretName - }); - if (overrideId) - deletedSecretIds.current.push({ - id: overrideId, - secretName - }); - remove(index); - // just the case if this is called from drawer - handlePopUpClose("secretDetails"); - }, - [] - ); - - const onCreateWsTag = useCallback( - async (tagName: string, tagColor: string) => { - try { - await createWsTag({ - workspaceID: workspaceId, - tagName, - tagColor, - tagSlug: tagName.replace(" ", "_") - }); - handlePopUpClose("addTag"); - createNotification({ - text: "Successfully created a tag", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to create a tag", - type: "error" - }); - } - }, - [workspaceId] - ); - - const handleFolderOpen = useCallback( - (id: string) => { - setSearchFilter(""); - router.push({ - pathname: router.pathname, - query: { - id: workspaceId, - env: environment, - folderId: id - } - }); - }, - [environment, workspaceId] - ); - - const isEditFolder = Boolean(popUp?.folderForm?.data); - - // FOLDER SECTION - const handleFolderCreate = async (name: string) => { - try { - await createFolder({ - workspaceId, - environment, - folderName: name, - parentFolderId: folderId - }); - createNotification({ - type: "success", - text: "Successfully created folder" - }); - handlePopUpClose("folderForm"); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to create folder", - type: "error" - }); - } - }; - - const handleFolderUpdate = useCallback( - async (name: string) => { - const { id } = popUp?.folderForm?.data as TDeleteFolderForm; - try { - await updateFolder({ - folderId: id, - workspaceId, - environment, - name - }); - createNotification({ - type: "success", - text: "Successfully updated folder" - }); - handlePopUpClose("folderForm"); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to update folder", - type: "error" - }); - } - }, - [environment, (popUp?.folderForm?.data as TDeleteFolderForm)?.id] - ); - - const handleFolderDelete = useCallback(async () => { - const { id } = popUp?.deleteFolder?.data as TDeleteFolderForm; - try { - deleteFolder({ - workspaceId, - environment, - folderId: id - }); - createNotification({ - type: "success", - text: "Successfully removed folder" - }); - handlePopUpClose("deleteFolder"); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to remove folder", - type: "error" - }); - } - }, [(popUp?.deleteFolder?.data as TDeleteFolderForm)?.id]); - - // SECRET IMPORT SECTION - const handleSecretImportCreate = async (env: string, secPath: string) => { - try { - await createSecretImport({ - workspaceId, - environment, - folderId, - secretImport: { - environment: env, - secretPath: secPath - } - }); - createNotification({ - type: "success", - text: "Successfully create secret link" - }); - handlePopUpClose("addSecretImport"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to create secret link", - type: "error" - }); - } - }; - - const handleSecretImportDelete = async () => { - const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport - ?.data as TDeleteSecretImport; - try { - if (secretImportCfg?._id) { - await deleteSecretImport({ - workspaceId, - environment, - folderId, - id: secretImportCfg?._id, - secretImportEnv: importEnv, - secretImportPath: impSecPath - }); - handlePopUpClose("deleteSecretImport"); - createNotification({ - type: "success", - text: "Successfully removed secret link" - }); - } - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to remove secret link", - type: "error" - }); - } - }; - - const handleDragEnd = (evt: DragEndEvent) => { - const { active, over } = evt; - if (over?.id && active.id !== over.id) { - const oldIndex = items.findIndex(({ id }) => id === active.id); - const newIndex = items.findIndex(({ id }) => id === over.id); - const newImportOrder = arrayMove(items, oldIndex, newIndex); - setItems(newImportOrder); - updateSecretImportSync({ - workspaceId, - environment, - folderId, - id: secretImportCfg?._id || "", - secretImports: newImportOrder.map((el) => ({ - environment: el.environment, - secretPath: el.secretPath - })) - }); - } - }; - - // OPTIMIZATION HOOKS PURELY FOR PERFORMANCE AND TO AVOID RE-RENDERING - const handleCreateTagModalOpen = useCallback(() => handlePopUpOpen("addTag"), []); - const handleFolderCreatePopUpOpen = useCallback( - (id: string, name: string) => handlePopUpOpen("folderForm", { id, name }), - [] - ); - const handleFolderDeletePopUpOpen = useCallback( - (id: string, name: string) => handlePopUpOpen("deleteFolder", { id, name }), - [] - ); - const handleSecretImportDelPopUpOpen = useCallback( - (impSecEnv: string, impSecPath: string) => - handlePopUpOpen("deleteSecretImport", { - environment: impSecEnv, - secretPath: impSecPath - }), - [] - ); - - // when secrets is not loading and secrets list is empty - const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length; - - // folder list checks - const isFolderListLoading = isRollbackMode ? isSnapshotSecretsLoading : isFoldersLoading; - const folderList = isRollbackMode ? snapshotSecret?.folders : folderData?.folders; - - // when using snapshot mode and snapshot is loading and snapshot list is empty - const isFoldersEmpty = !isFolderListLoading && !folderList?.length; - const isSnapshotSecretEmtpy = - isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length; - const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy; - const isSecretImportEmpty = !secretImportCfg?.imports?.length; - const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty; - - if (isSecretsLoading) { - return ( -
    - loading animation -
    - ); - } - - return ( -
    -
    - {/* breadcrumb row */} -
    - envir.slug === environment)?.[0]?.name || "" - } - isFolderMode - folders={folderData?.dir} - isProjectRelated - userAvailableEnvs={userAvailableEnvs} - onEnvChange={onEnvChange} - /> -
    -
    -
    {isRollbackMode ? "Secret Snapshot" : ""}
    - {isRollbackMode && Boolean(snapshotSecret) && ( - - {new Date(snapshotSecret?.createdAt || "").toLocaleString()} - - )} -
    - {/* Environment, search and other action row */} -
    -
    - setSearchFilter(e.target.value)} - leftIcon={} - /> -
    -
    -
    - - - - - - - -
    - -
    -
    -
    -
    -
    - - setIsSecretValueHidden.toggle()} - > - - - -
    - - {(isAllowed) => ( -
    - - handlePopUpOpen("secretSnapshots")} - > - - - -
    - )} -
    - - {(isAllowed) => ( -
    - -
    - )} -
    - {!isReadOnly && !isRollbackMode && ( -
    - - {(isAllowed) => ( - - )} - - - -
    - -
    -
    - -
    -
    - - {(isAllowed) => ( - - )} - -
    -
    - - {(isAllowed) => ( - - )} - -
    -
    -
    -
    -
    - )} - {isRollbackMode && ( - - )} - - - - -
    -
    -
    - {!isEmptyPage && ( - - - - - - - - {permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ) ? ( - fields.map(({ id, _id }, index) => ( - - )) - ) : ( - - - - )} - {!isReadOnly && !isRollbackMode && ( - - - - )} - -
    - -
    - - {(isAllowed) => ( - - )} - -
    -
    -
    - )} - - handlePopUpToggle("secretSnapshots", isOpen)} - fetchNextPage={fetchNextPage} - hasNextPage={hasNextPage} - snapshotId={snapshotId} - isFetchingNextPage={isFetchingNextPage} - secretSnaphots={secretSnaphots} - onSelectSnapshot={setSnaphotId} - /> - handlePopUpToggle("secretDetails", isOpen)} - secretVersion={secretVersion} - index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index} - onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)} - /> - - -
    - {/* secrets table and drawers, modals */} -
    - {/* Create a new tag modal */} - { - handlePopUpToggle("addTag", open); - }} - > - - - - - {/* Uploaded env override or not confirmation modal */} - handlePopUpToggle("uploadedSecOpts", open)} - > - handlePopUpClose("uploadedSecOpts")} - > - Keep old - , - - ]} - > -
    -
    Your file contains following duplicate secrets
    -
    - {Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {}) - ?.map((key) => key) - .join(", ")} -
    -
    Are you sure you want to overwrite these secrets?
    -
    -
    -
    - handlePopUpToggle("folderForm", isOpen)} - > - - - - - handlePopUpToggle("addSecretImport", isOpen)} - > - - - - - handlePopUpToggle("deleteFolder", isOpen)} - onDeleteApproved={handleFolderDelete} - /> - handlePopUpToggle("deleteSecretImport", isOpen)} - onDeleteApproved={handleSecretImportDelete} - /> - handlePopUpToggle("compareSecrets", open)} - > - - - - - {subscription && ( - handlePopUpToggle("upgradePlan", isOpen)} - text={ - subscription.slug === null - ? "You can perform point-in-time recovery under an Enterprise license" - : "You can perform point-in-time recovery if you switch to Infisical's Team plan" - } - /> - )} -
    - ); -}; diff --git a/frontend/src/views/DashboardPage/DashboardPage.utils.ts b/frontend/src/views/DashboardPage/DashboardPage.utils.ts deleted file mode 100644 index bd0131e8e..000000000 --- a/frontend/src/views/DashboardPage/DashboardPage.utils.ts +++ /dev/null @@ -1,289 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import crypto from "crypto"; - -import * as yup from "yup"; - -import { - decryptAssymmetric, - encryptSymmetric -} from "@app/components/utilities/cryptography/crypto"; -import { BatchSecretDTO, DecryptedSecret } from "@app/hooks/api/secrets/types"; - -export enum SecretActionType { - Created = "created", - Modified = "modified", - Deleted = "deleted" -} - -export const DEFAULT_SECRET_VALUE = { - _id: undefined, - overrideAction: undefined, - idOverride: undefined, - valueOverride: undefined, - comment: "", - key: "", - value: "", - tags: [] -}; - -const secretSchema = yup.object({ - _id: yup.string(), - key: yup - .string() - .trim() - .required() - .label("Secret key") - .test("starts-with-number", "Should start with an alphabet", (val) => - Boolean(val?.charAt(0)?.match(/[a-zA-Z]/i)) - ) - .test({ - name: "duplicate-keys", - // TODO:(akhilmhdh) ts keeps throwing from not found need to see how to resolve this - test: (val, ctx: any) => { - const secrets: Array<{ key: string }> = ctx?.from?.[1]?.value?.secrets || []; - const duplicateKeys: Record = {}; - secrets?.forEach(({ key }, index) => { - if (key === val) duplicateKeys[index + 1] = true; - }); - const pos = Object.keys(duplicateKeys); - if (pos.length <= 1) { - return true; - } - return ctx.createError({ message: `Same key in row ${pos.join(", ")}` }); - } - }), - value: yup.string().trim(), - comment: yup.string().trim(), - tags: yup.array( - yup.object({ - _id: yup.string().required(), - name: yup.string().required(), - slug: yup.string().required(), - tagColor: yup.string().nullable(), - }) - ), - overrideAction: yup.string().notRequired().oneOf(Object.values(SecretActionType)), - idOverride: yup.string().notRequired(), - valueOverride: yup.string().trim().notRequired() -}); - -export const schema = yup.object({ - isSnapshotMode: yup.bool().notRequired(), - secrets: yup.array(secretSchema) -}); - -export type FormData = yup.InferType; -export type TSecretDetailsOpen = { index: number; id: string }; -export type TSecOverwriteOpt = { secrets: Record }; - -// to convert multi line into single line ones by quoting them and changing to string \n -const formatMultiValueEnv = (val?: string) => { - if (!val) return ""; - if (!val.match("\n")) return val; - return `"${val.replace(/\n/g, "\\n")}"`; -}; - -export const downloadSecret = ( - secrets: FormData["secrets"] = [], - importedSecrets: { key: string; value?: string; comment?: string }[] = [], - env: string = "unknown" -) => { - const importSecPos: Record = {}; - importedSecrets.forEach((el, index) => { - importSecPos[el.key] = index; - }); - const finalSecret = [...importedSecrets]; - secrets.forEach(({ key, value, valueOverride, overrideAction, comment }) => { - const finalVal = - overrideAction && overrideAction !== SecretActionType.Deleted ? valueOverride : value; - const newValue = { - key, - value: formatMultiValueEnv(finalVal), - comment - }; - // can also be zero thus failing - if (typeof importSecPos?.[key] === "undefined") { - finalSecret.push(newValue); - } else { - finalSecret[importSecPos[key]] = newValue; - } - }); - - let file = ""; - finalSecret.forEach(({ key, value, comment }) => { - if (comment) { - file += `# ${comment}\n${key}=${value}\n`; - return; - } - file += `${key}=${value}\n`; - }); - - const blob = new Blob([file]); - const fileDownloadUrl = URL.createObjectURL(blob); - const alink = document.createElement("a"); - alink.href = fileDownloadUrl; - alink.download = `${env}.env`; - alink.click(); -}; - -/* - * Below functions are used convert the dashboard secrets to the bulk secret creation request format - * They are encrypted back - * Formatted to [ { request: "", secret:{} } ] - */ -const encryptASecret = (randomBytes: string, key: string, value?: string, comment?: string) => { - // encrypt key - const { - ciphertext: secretKeyCiphertext, - iv: secretKeyIV, - tag: secretKeyTag - } = encryptSymmetric({ - plaintext: key, - key: randomBytes - }); - - // encrypt value - const { - ciphertext: secretValueCiphertext, - iv: secretValueIV, - tag: secretValueTag - } = encryptSymmetric({ - plaintext: value ?? "", - key: randomBytes - }); - - // encrypt comment - const { - ciphertext: secretCommentCiphertext, - iv: secretCommentIV, - tag: secretCommentTag - } = encryptSymmetric({ - plaintext: comment ?? "", - key: randomBytes - }); - - return { - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag - }; -}; - -const deepCompareSecrets = (lhs: DecryptedSecret, rhs: any) => - lhs.key === rhs.key && - lhs.value === rhs.value && - lhs.comment === rhs.comment && - lhs?.valueOverride === rhs?.valueOverride && - JSON.stringify(lhs.tags) === JSON.stringify(rhs.tags); - -export const transformSecretsToBatchSecretReq = ( - deletedSecretIds: { id: string; secretName: string; }[], - latestFileKey: any, - secrets: FormData["secrets"], - intialValues: DecryptedSecret[] = [] -) => { - // deleted secrets - const secretsToBeDeleted: BatchSecretDTO["requests"] = deletedSecretIds.map(({ id, secretName }) => ({ - method: "DELETE", - secret: { - _id: id, - secretName - } - })); - - const secretsToBeUpdated: BatchSecretDTO["requests"] = []; - const secretsToBeCreated: BatchSecretDTO["requests"] = []; - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; - - const randomBytes = latestFileKey - ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) - : crypto.randomBytes(16).toString("hex"); - - secrets?.forEach((secret) => { - const { - _id, - idOverride, - value, - valueOverride, - overrideAction, - tags = [], - comment, - key - } = secret; - if (!idOverride && overrideAction === SecretActionType.Created) { - secretsToBeCreated.push({ - method: "POST", - secret: { - type: "personal", - tags, - secretName: key, - ...encryptASecret(randomBytes, key, valueOverride, comment) - } - }); - } - // to be created ones as they don't have server generated id - if (!_id) { - secretsToBeCreated.push({ - method: "POST", - secret: { - type: "shared", - tags, - secretName: key, - ...encryptASecret(randomBytes, key, value, comment) - } - }); - return; // exit as updated and delete case won't happen when created - } - // has an id means this is updated one - if (_id) { - // check value has changed or not - const initialSecretValue = intialValues?.find(({ _id: secId }) => secId === _id)!; - if (!deepCompareSecrets(initialSecretValue, secret)) { - secretsToBeUpdated.push({ - method: "PATCH", - secret: { - _id, - type: "shared", - tags, - secretName: key, - ...encryptASecret(randomBytes, key, value, comment) - } - }); - } - } - if (idOverride) { - // if action is deleted meaning override has been removed but id is kept to collect at this point - if (overrideAction === SecretActionType.Deleted) { - secretsToBeDeleted.push({ method: "DELETE", secret: { _id: idOverride, secretName: key } }); - } else { - // if not deleted action then as id is there its an updated - const initialSecretValue = intialValues?.find(({ _id: secId }) => secId === _id)!; - if (!deepCompareSecrets(initialSecretValue, secret)) { - secretsToBeUpdated.push({ - method: "PATCH", - secret: { - _id: idOverride, - type: "personal", - tags, - secretName: key, - ...encryptASecret(randomBytes, key, valueOverride, comment) - } - }); - } - } - } - }); - - return secretsToBeCreated.concat(secretsToBeUpdated, secretsToBeDeleted); -}; diff --git a/frontend/src/views/DashboardPage/components/CompareSecret/CompareSecret.tsx b/frontend/src/views/DashboardPage/components/CompareSecret/CompareSecret.tsx deleted file mode 100644 index a14ccedd2..000000000 --- a/frontend/src/views/DashboardPage/components/CompareSecret/CompareSecret.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useCallback } from "react"; - -import { FormControl, Input, Spinner } from "@app/components/v2"; -import { useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api"; - -type SecretValueProps = { - workspaceId: string; - envName: string; - env: string; - secretKey: string; -}; - -const SecretValue = ({ workspaceId, env, envName, secretKey }: SecretValueProps) => { - const { data: latestFileKey } = useGetUserWsKey(workspaceId); - const { data: secret, isLoading: isSecretsLoading } = useGetProjectSecrets({ - workspaceId, - env, - decryptFileKey: latestFileKey! - }); - - const getValue = useCallback( - (data: typeof secret) => { - const sec = data?.secrets?.find(({ key: secKey }) => secKey === secretKey); - return sec?.value || "Not found"; - }, - [secretKey] - ); - - return ( - - : undefined} - /> - - ); -}; - -type Props = { - workspaceId: string; - secretKey: string; - envs: Array<{ name: string; slug: string }>; -}; - -export const CompareSecret = ({ workspaceId, secretKey, envs }: Props): JSX.Element => { - // should not do anything until secretKey is available - if (!secretKey) return
    ; - - return ( -
    - {envs.map(({ name, slug }) => ( - - ))} -
    - ); -}; diff --git a/frontend/src/views/DashboardPage/components/CompareSecret/index.tsx b/frontend/src/views/DashboardPage/components/CompareSecret/index.tsx deleted file mode 100644 index 260a702df..000000000 --- a/frontend/src/views/DashboardPage/components/CompareSecret/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { CompareSecret } from "./CompareSecret"; diff --git a/frontend/src/views/DashboardPage/components/CreateTagModal/CreateTagModal.tsx b/frontend/src/views/DashboardPage/components/CreateTagModal/CreateTagModal.tsx deleted file mode 100644 index b399ee886..000000000 --- a/frontend/src/views/DashboardPage/components/CreateTagModal/CreateTagModal.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { - faCheck -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; - -import { Button, FormControl, Input, ModalClose, Tooltip } from "@app/components/v2"; - -import { isValidHexColor } from "../../../../components/utilities/isValidHexColor"; -import { secretTagsColors } from "../../../../const" -import { TagColor } from "../../../../hooks/api/tags/types"; - - -type Props = { - onCreateTag: (tagName: string, tagColor: string) => Promise; -}; - -const createTagSchema = yup.object({ - name: yup.string().required().trim().label("Tag Name") -}); -type FormData = yup.InferType; - -export const CreateTagModal = ({ onCreateTag }: Props): JSX.Element => { - const { - control, - reset, - formState: { isSubmitting }, - handleSubmit - } = useForm({ - resolver: yupResolver(createTagSchema) - }); - - const [tagsColors] = useState(secretTagsColors) - const [selectedTagColor, setSelectedTagColor] = useState(tagsColors[0]) - const [showHexInput, setShowHexInput] = useState(false) - const [tagColor, setTagColor] = useState("") - - - const onFormSubmit = async ({ name }: FormData) => { - await onCreateTag(name, tagColor); - reset(); - }; - - useEffect(() => { - const clonedTagColors = [...tagsColors] - const selectedTagBgColor = clonedTagColors.find($tagColor => $tagColor.selected); - - if (selectedTagBgColor) { - setSelectedTagColor(selectedTagBgColor); - setTagColor(selectedTagBgColor.hex); - } - }, []) - - useEffect(() => { - const tagsList = document.querySelector(".secret-tags-wrapper") - const tagsHexWrapper = document.querySelector(".tags-hex-wrapper") - - if (showHexInput) { - tagsList?.classList.add("hide-tags") - tagsList?.classList.remove("show-tags") - tagsHexWrapper?.classList.add("show-hex-input") - tagsHexWrapper?.classList.remove("hide-hex-input") - } else { - tagsList?.classList.remove("hide-tags") - tagsList?.classList.add("show-tags") - tagsHexWrapper?.classList.remove("show-hex-input") - tagsHexWrapper?.classList.add("hide-hex-input") - } - }, [showHexInput]) - - - const handleColorChange = (clickedTagColor: TagColor) => { - const updatedTagColors = [...tagsColors]; - const clickedTagColorIndex = updatedTagColors.findIndex(($tagColor) => $tagColor.id === clickedTagColor.id); - const updatedClickedTagColor = updatedTagColors[clickedTagColorIndex]; - - updatedTagColors.forEach((tgColor) => { - // eslint-disable-next-line no-param-reassign - tgColor.selected = false; - }); - - if (selectedTagColor.id !== clickedTagColor.id) { - updatedClickedTagColor.selected = !updatedClickedTagColor.selected; - setSelectedTagColor(updatedClickedTagColor); - setTagColor(updatedClickedTagColor.hex); - } - }; - - return ( -
    - ( - - - - )} - /> - -
    -
    Tag Color
    -
    -
    -
    -
    - -
    -
    - { - tagsColors.map(($tagColor: TagColor) => { - return ( -
    - -
    handleColorChange($tagColor)} - tabIndex={0} role="button" - onKeyDown={() => { }} - > - { - $tagColor.selected && - } -
    -
    -
    - ) - }) - } -
    - -
    -
    - { - isValidHexColor(tagColor) && ( -
    - -
    - ) - } - - { - !isValidHexColor(tagColor) && ( -
    - ) - } -
    -
    - ) => setTagColor(e.target.value)} - /> -
    -
    - -
    -
    -
    setShowHexInput((prev) => !prev)} style={{ border: "1px solid rgba(220, 216, 254, 0.376)" }} - tabIndex={0} role="button" - onKeyDown={() => { }}> - { - !showHexInput && # - } -
    -
    -
    -
    -
    - -
    - - - - -
    - - ); -}; diff --git a/frontend/src/views/DashboardPage/components/CreateTagModal/index.tsx b/frontend/src/views/DashboardPage/components/CreateTagModal/index.tsx deleted file mode 100644 index a448ba55c..000000000 --- a/frontend/src/views/DashboardPage/components/CreateTagModal/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export {CreateTagModal} from "./CreateTagModal" \ No newline at end of file diff --git a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx b/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx deleted file mode 100644 index 4c02fab91..000000000 --- a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { memo } from "react"; -import { subject } from "@casl/ability"; -import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { IconButton, Tooltip } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; - -type Props = { - folders?: Array<{ id: string; name: string }>; - search?: string; - environment: string; - secretPath: string; - onFolderUpdate: (folderId: string, name: string) => void; - onFolderDelete: (folderId: string, name: string) => void; - onFolderOpen: (folderId: string) => void; -}; - -export const FolderSection = memo( - ({ - onFolderUpdate: handleFolderUpdate, - onFolderDelete: handleFolderDelete, - onFolderOpen: handleFolderOpen, - search = "", - folders = [], - environment, - secretPath - }: Props) => { - return ( - <> - {folders - .filter(({ name }) => name.toLowerCase().includes(search.toLowerCase())) - .map(({ id, name }) => ( - - - - - -
    null} - tabIndex={0} - role="button" - onClick={() => handleFolderOpen(id)} - > - {name} -
    -
    - - {(isAllowed) => ( -
    - - handleFolderUpdate(id, name)} - ariaLabel="expand" - > - - - -
    - )} -
    - - {(isAllowed) => ( -
    - - handleFolderDelete(id, name)} - > - - - -
    - )} -
    -
    - - - ))} - - ); - } -); - -FolderSection.displayName = "FolderSection"; diff --git a/frontend/src/views/DashboardPage/components/FolderSection/index.tsx b/frontend/src/views/DashboardPage/components/FolderSection/index.tsx deleted file mode 100644 index 861ebd934..000000000 --- a/frontend/src/views/DashboardPage/components/FolderSection/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export { FolderForm } from "./FolderForm"; -export { FolderSection } from "./FolderSection"; -export * from "./types"; diff --git a/frontend/src/views/DashboardPage/components/FolderSection/types.ts b/frontend/src/views/DashboardPage/components/FolderSection/types.ts deleted file mode 100644 index f2fe30b10..000000000 --- a/frontend/src/views/DashboardPage/components/FolderSection/types.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type TEditFolderForm = { id: string; name: string }; -export type TDeleteFolderForm = { id: string; name: string }; diff --git a/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx deleted file mode 100644 index ef85c4896..000000000 --- a/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { useFormContext, useWatch } from "react-hook-form"; -import { subject } from "@casl/ability"; -import { faCircle, faCircleDot, faShuffle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - Button, - Drawer, - DrawerContent, - FormControl, - Input, - Popover, - PopoverContent, - PopoverTrigger, - Switch, - TextArea -} from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; -import { useToggle } from "@app/hooks"; - -import { FormData, SecretActionType } from "../../DashboardPage.utils"; -import { GenRandomNumber } from "./GenRandomNumber"; - -type Props = { - isDrawerOpen: boolean; - environment: string; - secretPath: string; - onOpenChange: (isOpen: boolean) => void; - index: number; - isReadOnly?: boolean; - onEnvCompare: (secretKey: string) => void; - secretVersion?: Array<{ id: string; createdAt: string; value: string }>; - // to record the ids of deleted ones - onSecretDelete: (index: number, secretName: string, id?: string, overrideId?: string) => void; - onSave: () => void; -}; - -export const SecretDetailDrawer = ({ - isDrawerOpen, - onOpenChange, - index, - secretVersion = [], - isReadOnly, - onSecretDelete, - onSave, - onEnvCompare, - environment, - secretPath -}: Props): JSX.Element => { - const [canRevealSecVal, setCanRevealSecVal] = useToggle(); - const [canRevealSecOverride, setCanRevealSecOverride] = useToggle(); - - const { register, setValue, control, getValues } = useFormContext(); - - const overrideAction = useWatch({ control, name: `secrets.${index}.overrideAction` }); - const isOverridden = - overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified; - - const onSecretOverride = () => { - const secret = getValues(`secrets.${index}`); - if (isOverridden) { - // when user created a new override but then removes - if (SecretActionType.Created) { - setValue(`secrets.${index}.valueOverride`, "", { shouldDirty: true }); - } - setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, { shouldDirty: true }); - } else { - setValue( - `secrets.${index}.overrideAction`, - secret?.idOverride ? SecretActionType.Modified : SecretActionType.Created, - { shouldDirty: true } - ); - } - }; - - return ( - - -
    - -
    -
    - - {(isAllowed) => ( - - )} - - - {(isAllowed) => ( - - )} - -
    -
    - } - > -
    - - - - - - - - - } - /> - - - setValue(`secrets.${index}.value`, val, { shouldDirty: true }) - } - /> - - - -
    - - Override with a personal value - -
    - - - - - - } - /> - - - setValue(`secrets.${index}.valueOverride`, val, { shouldDirty: true }) - } - /> - - - -
    -
    Version History
    -
    - {secretVersion?.map(({ createdAt, value, id }, i) => ( -
    -
    -
    - -
    -
    - {new Date(createdAt).toLocaleDateString("en-US", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - })} -
    -
    -
    -
    Value:
    -
    {value}
    -
    -
    - ))} -
    -
    - -