diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index a2664c7b0..422aaa5ec 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -15,6 +15,7 @@ import * as userController from "./userController"; import * as workspaceController from "./workspaceController"; import * as secretScanningController from "./secretScanningController"; import * as webhookController from "./webhookController"; +import * as secretImportController from "./secretImportController"; export { authController, @@ -33,5 +34,6 @@ export { userController, workspaceController, secretScanningController, - webhookController + webhookController, + secretImportController }; diff --git a/backend/src/controllers/v1/secretImportController.ts b/backend/src/controllers/v1/secretImportController.ts new file mode 100644 index 000000000..a55b20a94 --- /dev/null +++ b/backend/src/controllers/v1/secretImportController.ts @@ -0,0 +1,117 @@ +import { Request, Response } from "express"; +import { validateMembership } from "../../helpers"; +import SecretImport from "../../models/secretImports"; +import { getAllImportedSecrets } from "../../services/SecretImportService"; +import { BadRequestError } from "../../utils/errors"; +import { ADMIN, MEMBER } from "../../variables"; + +export const createSecretImport = async (req: Request, res: Response) => { + const { workspaceId, environment, folderId, secretImport } = req.body; + const importSecDoc = await SecretImport.findOne({ + workspace: workspaceId, + environment, + folderId + }); + + if (!importSecDoc) { + const doc = new SecretImport({ + workspace: workspaceId, + environment, + folderId, + imports: [{ environment: secretImport.environment, secretPath: secretImport.secretPath }] + }); + await doc.save(); + return res.status(200).json({ message: "successfully created secret import" }); + } + + const doesImportExist = importSecDoc.imports.find( + (el) => el.environment === secretImport.environment && el.secretPath === secretImport.secretPath + ); + if (doesImportExist) { + throw BadRequestError({ message: "Secret import already exist" }); + } + + importSecDoc.imports.push({ + environment: secretImport.environment, + secretPath: secretImport.secretPath + }); + await importSecDoc.save(); + return res.status(200).json({ message: "successfully created secret import" }); +}; + +// to keep the ordering, you must pass all the imports in here not the only updated one +// this is because the order decide which import gets overriden +export const updateSecretImport = async (req: Request, res: Response) => { + const { id } = req.params; + const { secretImports } = req.body; + const importSecDoc = await SecretImport.findById(id); + if (!importSecDoc) { + throw BadRequestError({ message: "Import not found" }); + } + + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: importSecDoc.workspace, + acceptedRoles: [ADMIN, MEMBER] + }); + + importSecDoc.imports = secretImports; + await importSecDoc.save(); + return res.status(200).json({ message: "successfully updated secret import" }); +}; + +export const deleteSecretImport = async (req: Request, res: Response) => { + const { id } = req.params; + const { secretImportEnv, secretImportPath } = req.body; + const importSecDoc = await SecretImport.findById(id); + if (!importSecDoc) { + throw BadRequestError({ message: "Import not found" }); + } + + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: importSecDoc.workspace, + acceptedRoles: [ADMIN, MEMBER] + }); + importSecDoc.imports = importSecDoc.imports.filter( + ({ environment, secretPath }) => + !(environment === secretImportEnv && secretPath === secretImportPath) + ); + await importSecDoc.save(); + return res.status(200).json({ message: "successfully delete secret import" }); +}; + +export const getSecretImports = async (req: Request, res: Response) => { + const { workspaceId, environment, folderId } = req.query; + const importSecDoc = await SecretImport.findOne({ + workspace: workspaceId, + environment, + folderId + }); + + if (!importSecDoc) { + return res.status(200).json({ secretImport: {} }); + } + + return res.status(200).json({ secretImport: importSecDoc }); +}; + +export const getAllSecretsFromImport = async (req: Request, res: Response) => { + const { workspaceId, environment, folderId } = req.query as { + workspaceId: string; + environment: string; + folderId: string; + }; + const importSecDoc = await SecretImport.findOne({ + workspace: workspaceId, + environment, + folderId + }); + + if (!importSecDoc) { + return res.status(200).json({ secrets: {} }); + } + + const secrets = await getAllImportedSecrets(workspaceId, environment, folderId); + return res.status(200).json({ secrets }); +}; diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index cc1aae055..ea8098e37 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -35,6 +35,7 @@ import { } from "../../services/FolderService"; import { isValidScope } from "../../helpers/secrets"; import path from "path"; +import { getAllImportedSecrets } from "../../services/SecretImportService"; /** * Peform a batch of any specified CUD secret operations @@ -690,7 +691,7 @@ export const getSecrets = async (req: Request, res: Response) => { } */ - const { tagSlugs, secretPath } = req.query; + const { tagSlugs, secretPath, include_imports } = req.query; let { folderId } = req.query; const workspaceId = req.query.workspaceId as string; const environment = req.query.environment as string; @@ -827,6 +828,12 @@ export const getSecrets = async (req: Request, res: Response) => { secrets = await Secret.find(secretQuery).populate("tags"); } + // TODO(akhilmhdh) - secret-imp change this to org type + let importedSecrets: any[] = []; + if (include_imports) { + importedSecrets = await getAllImportedSecrets(workspaceId, environment, folderId as string); + } + const channel = getChannelFromUserAgent(req.headers["user-agent"]); const readAction = await EELogService.createAction({ @@ -868,7 +875,8 @@ export const getSecrets = async (req: Request, res: Response) => { } return res.status(200).send({ - secrets + secrets, + ...(include_imports && { imports: importedSecrets }) }); }; diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index e1edd95ad..cd2d3248e 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -5,6 +5,10 @@ import { eventPushSecrets } from "../../events"; import { BotService } from "../../services"; import { repackageSecretToRaw } from "../../helpers/secrets"; import { encryptSymmetric128BitHexKeyUTF8 } from "../../utils/crypto"; +import { getAllImportedSecrets } from "../../services/SecretImportService"; +import Folder from "../../models/folder"; +import { getFolderByPath } from "../../services/FolderService"; +import { BadRequestError } from "../../utils/errors"; /** * Return secrets for workspace with id [workspaceId] and environment @@ -16,6 +20,7 @@ export const getSecretsRaw = async (req: Request, res: Response) => { const workspaceId = req.query.workspaceId as string; const environment = req.query.environment as string; const secretPath = req.query.secretPath as string; + const includeImports = req.query.include_imports as string; const secrets = await SecretService.getSecrets({ workspaceId: new Types.ObjectId(workspaceId), @@ -28,13 +33,38 @@ export const getSecretsRaw = async (req: Request, res: Response) => { workspaceId: new Types.ObjectId(workspaceId) }); + if (includeImports) { + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + let folderId = "root"; + // if folder exist get it and replace folderid with new one + if (folders) { + const folder = getFolderByPath(folders.nodes, secretPath as string); + if (!folder) { + throw BadRequestError({ message: "Folder not found" }); + } + folderId = folder.id; + } + const importedSecrets = await getAllImportedSecrets(workspaceId, environment, folderId); + return res.status(200).send({ + secrets: secrets.map((secret) => + repackageSecretToRaw({ + secret, + key + }) + ), + imports: importedSecrets.map((el) => ({ + ...el, + secrets: el.secrets.map((secret) => repackageSecretToRaw({ secret, key })) + })) + }); + } + return res.status(200).send({ secrets: secrets.map((secret) => { const rep = repackageSecretToRaw({ secret, key }); - return rep; }) }); @@ -232,6 +262,7 @@ export const getSecrets = async (req: Request, res: Response) => { const workspaceId = req.query.workspaceId as string; const environment = req.query.environment as string; const secretPath = req.query.secretPath as string; + const includeImports = req.query.include_imports as string; const secrets = await SecretService.getSecrets({ workspaceId: new Types.ObjectId(workspaceId), @@ -240,6 +271,24 @@ export const getSecrets = async (req: Request, res: Response) => { authData: req.authData }); + if (includeImports) { + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + let folderId = "root"; + // if folder exist get it and replace folderid with new one + if (folders) { + const folder = getFolderByPath(folders.nodes, secretPath as string); + if (!folder) { + throw BadRequestError({ message: "Folder not found" }); + } + folderId = folder.id; + } + const importedSecrets = await getAllImportedSecrets(workspaceId, environment, folderId); + return res.status(200).send({ + secrets, + imports: importedSecrets + }); + } + return res.status(200).send({ secrets }); diff --git a/backend/src/index.ts b/backend/src/index.ts index b5f1b6ddd..a8a111f53 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -41,8 +41,9 @@ import { signup as v1SignupRouter, userAction as v1UserActionRouter, user as v1UserRouter, + workspace as v1WorkspaceRouter, webhooks as v1WebhooksRouter, - workspace as v1WorkspaceRouter + secretImport as v1SecretImportRouter } from "./routes/v1"; import { auth as v2AuthRouter, @@ -151,6 +152,7 @@ const main = async () => { app.use("/api/v1/folders", v1SecretsFolder); app.use("/api/v1/secret-scanning", v1SecretScanningRouter); app.use("/api/v1/webhooks", v1WebhooksRouter); + app.use("/api/v1/secret-imports", v1SecretImportRouter); // v2 routes (improvements) app.use("/api/v2/signup", v2SignupRouter); diff --git a/backend/src/models/secretImports.ts b/backend/src/models/secretImports.ts new file mode 100644 index 000000000..0041abc29 --- /dev/null +++ b/backend/src/models/secretImports.ts @@ -0,0 +1,52 @@ +import { Schema, Types, model } from "mongoose"; + +export interface ISecretImports { + _id: Types.ObjectId; + workspace: Types.ObjectId; + environment: string; + folderId: string; + imports: Array<{ + environment: string; + secretPath: string; + }>; +} + +const secretImportSchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + environment: { + type: String, + required: true + }, + folderId: { + type: String, + required: true, + default: "root" + }, + imports: { + type: [ + { + environment: { + type: String, + required: true + }, + secretPath: { + type: String, + required: true + } + } + ], + default: [] + } + }, + { + timestamps: true + } +); + +const SecretImport = model("SecretImports", secretImportSchema); +export default SecretImport; diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index b9f26d32d..ce5b5dd58 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 secretScanning from "./secretScanning"; import webhooks from "./webhook"; +import secretImport from "./secretImport"; export { signup, @@ -37,5 +38,6 @@ export { integrationAuth, secretsFolder, secretScanning, - webhooks + webhooks, + secretImport }; diff --git a/backend/src/routes/v1/secretImport.ts b/backend/src/routes/v1/secretImport.ts new file mode 100644 index 000000000..60ed16bc8 --- /dev/null +++ b/backend/src/routes/v1/secretImport.ts @@ -0,0 +1,84 @@ +import express from "express"; +const router = express.Router(); +import { body, param, query } from "express-validator"; +import { secretImportController } from "../../controllers/v1"; +import { requireAuth, requireWorkspaceAuth, validateRequest } from "../../middleware"; +import { ADMIN, AUTH_MODE_JWT, MEMBER } from "../../variables"; + +router.post( + "/", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: "body" + }), + body("workspaceId").exists().isString().trim().notEmpty(), + body("environment").exists().isString().trim().notEmpty(), + body("folderId").default("root").isString().trim(), + body("secretImport").exists().isObject(), + body("secretImport.environment").isString().exists().trim(), + body("secretImport.secretPath").isString().exists().trim(), + validateRequest, + secretImportController.createSecretImport +); + +router.put( + "/:id", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + param("id").exists().isString().trim(), + body("secretImports").exists().isArray(), + body("secretImports.*.environment").isString().exists().trim(), + body("secretImports.*.secretPath").isString().exists().trim(), + validateRequest, + secretImportController.updateSecretImport +); + +router.delete( + "/:id", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + param("id").exists().isString().trim(), + body("secretImportPath").isString().exists().trim(), + body("secretImportEnv").isString().exists().trim(), + validateRequest, + secretImportController.updateSecretImport +); + +router.get( + "/", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: "query" + }), + query("workspaceId").exists().isString().trim().notEmpty(), + query("environment").exists().isString().trim().notEmpty(), + query("folderId").default("root").isString().trim(), + validateRequest, + secretImportController.getSecretImports +); + +router.get( + "/secrets", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: "query" + }), + query("workspaceId").exists().isString().trim().notEmpty(), + query("environment").exists().isString().trim().notEmpty(), + query("folderId").default("root").isString().trim(), + validateRequest, + secretImportController.getAllSecretsFromImport +); + +export default router; diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index 731e58eb0..59665fd03 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -127,6 +127,7 @@ router.get( query("tagSlugs"), query("folderId").default("root").isString().trim(), query("secretPath").optional().isString().trim(), + query("include_imports").optional().default(false).isBoolean(), validateRequest, requireAuth({ acceptedAuthModes: [ diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index c010151c6..445503dec 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -1,10 +1,6 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - requireWorkspaceAuth, - validateRequest, -} from "../../middleware"; +import { requireAuth, requireWorkspaceAuth, validateRequest } from "../../middleware"; import { body, param, query } from "express-validator"; import { secretsController } from "../../controllers/v3"; import { @@ -17,7 +13,7 @@ import { PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS, SECRET_PERSONAL, - SECRET_SHARED, + SECRET_SHARED } from "../../variables"; router.get( @@ -25,14 +21,15 @@ router.get( query("workspaceId").exists().isString().trim(), query("environment").exists().isString().trim(), query("secretPath").default("/").isString().trim(), + query("include_imports").isBoolean().default(false), validateRequest, requireAuth({ acceptedAuthModes: [ AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_SERVICE_ACCOUNT, - ], + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -40,7 +37,7 @@ router.get( locationEnvironment: "query", requiredPermissions: [PERMISSION_READ_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true, + requireE2EEOff: true }), secretsController.getSecretsRaw ); @@ -58,8 +55,8 @@ router.get( AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_SERVICE_ACCOUNT, - ], + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -67,7 +64,7 @@ router.get( locationEnvironment: "query", requiredPermissions: [PERMISSION_READ_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true, + requireE2EEOff: true }), secretsController.getSecretByNameRaw ); @@ -86,8 +83,8 @@ router.post( AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_SERVICE_ACCOUNT, - ], + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -95,7 +92,7 @@ router.post( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true, + requireE2EEOff: true }), secretsController.createSecretRaw ); @@ -114,8 +111,8 @@ router.patch( AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_SERVICE_ACCOUNT, - ], + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -123,7 +120,7 @@ router.patch( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true, + requireE2EEOff: true }), secretsController.updateSecretByNameRaw ); @@ -141,8 +138,8 @@ router.delete( AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_SERVICE_ACCOUNT, - ], + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -150,7 +147,7 @@ router.delete( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: true, + requireE2EEOff: true }), secretsController.deleteSecretByNameRaw ); @@ -166,8 +163,8 @@ router.get( AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_SERVICE_ACCOUNT, - ], + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -175,7 +172,7 @@ router.get( locationEnvironment: "query", requiredPermissions: [PERMISSION_READ_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: false, + requireE2EEOff: false }), secretsController.getSecrets ); @@ -201,8 +198,8 @@ router.post( AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_SERVICE_ACCOUNT, - ], + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -210,7 +207,7 @@ router.post( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: false, + requireE2EEOff: false }), secretsController.createSecret ); @@ -228,15 +225,15 @@ router.get( AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_SERVICE_ACCOUNT, - ], + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], locationWorkspaceId: "query", locationEnvironment: "query", requiredPermissions: [PERMISSION_READ_SECRETS], - requireBlindIndicesEnabled: true, + requireBlindIndicesEnabled: true }), secretsController.getSecretByName ); @@ -257,8 +254,8 @@ router.patch( AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_SERVICE_ACCOUNT, - ], + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -266,7 +263,7 @@ router.patch( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: false, + requireE2EEOff: false }), secretsController.updateSecretByName ); @@ -284,8 +281,8 @@ router.delete( AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_SERVICE_ACCOUNT, - ], + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -293,7 +290,7 @@ router.delete( locationEnvironment: "body", requiredPermissions: [PERMISSION_WRITE_SECRETS], requireBlindIndicesEnabled: true, - requireE2EEOff: false, + requireE2EEOff: false }), secretsController.deleteSecretByName ); diff --git a/backend/src/services/SecretImportService.ts b/backend/src/services/SecretImportService.ts new file mode 100644 index 000000000..ec9f9f5c4 --- /dev/null +++ b/backend/src/services/SecretImportService.ts @@ -0,0 +1,82 @@ +import { Types } from "mongoose"; +import Folder from "../models/folder"; +import Secret, { ISecret } from "../models/secret"; +import SecretImport from "../models/secretImports"; +import { getFolderByPath } from "./FolderService"; + +type TSecretImportFid = { environment: string; folderId: string; secretPath: string }; + +export const getAllImportedSecrets = async ( + workspaceId: string, + environment: string, + folderId = "root" +) => { + const secImports = await SecretImport.findOne({ + workspace: workspaceId, + environment, + folderId + }); + if (!secImports) return []; + if (secImports.imports.length === 0) return []; + + const importedEnv: Record = {}; // to get folders from all environment + secImports.imports.forEach((el) => (importedEnv[el.environment] = true)); + + const folders = await Folder.find({ + workspace: workspaceId, + environment: { $in: Object.keys(importedEnv) } + }); + + 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 + }); + } + }); + if (importedSecByFid.length === 0) return []; + + const secsGroupedByRef = await Secret.aggregate([ + { + $match: { + workspace: new Types.ObjectId(workspaceId), + type: "shared" + } + }, + { + $group: { + _id: { + environment: "$environment", + folderId: "$folder" + }, + secrets: { $push: "$$ROOT" } + } + }, + { + $match: { + $or: importedSecByFid.map(({ environment, folderId: fid }) => ({ + "_id.environment": environment, + "_id.folderId": fid + })) + } + } + ]); + + // now let stitch together secrets. + const importedSecrets: Array = []; + importedSecByFid.forEach(({ environment, folderId, secretPath }) => { + const secretsGrouped = secsGroupedByRef.find( + (el) => el._id.environment === environment && el._id.folderId === folderId + ); + if (secretsGrouped) { + importedSecrets.push({ secretPath, folderId, environment, secrets: secretsGrouped.secrets }); + } + }); + return importedSecrets; +}; diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index 707b0d0ad..a0c1f8a5b 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -9,12 +9,12 @@ import SecretService from "./SecretService"; import GithubSecretScanningService from "./GithubSecretScanningService" export { - TelemetryService, - DatabaseService, - BotService, - EventService, - IntegrationService, - TokenService, - SecretService, - GithubSecretScanningService -} \ No newline at end of file + TelemetryService, + DatabaseService, + BotService, + EventService, + IntegrationService, + TokenService, + SecretService, + GithubSecretScanningService +}; \ No newline at end of file diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 5afec6782..89c8db76a 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -235,6 +235,10 @@ func CallGetSecretsV3(httpClient *resty.Client, request GetEncryptedSecretsV3Req SetQueryParam("environment", request.Environment). SetQueryParam("workspaceId", request.WorkspaceId) + if request.IncludeImport { + httpRequest.SetQueryParam("include_imports", "true") + } + if request.SecretPath != "" { httpRequest.SetQueryParam("secretPath", request.SecretPath) } diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 09e6779e0..027243ef5 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -272,40 +272,51 @@ type GetNewAccessTokenWithRefreshTokenResponse struct { } type GetEncryptedSecretsV3Request struct { - Environment string `json:"environment"` - WorkspaceId string `json:"workspaceId"` - SecretPath string `json:"secretPath"` + Environment string `json:"environment"` + WorkspaceId string `json:"workspaceId"` + SecretPath string `json:"secretPath"` + IncludeImport bool `json:"include_imports"` +} + +type EncryptedSecretV3 struct { + ID string `json:"_id"` + Version int `json:"version"` + Workspace string `json:"workspace"` + Type string `json:"type"` + Tags []struct { + ID string `json:"_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Workspace string `json:"workspace"` + } `json:"tags"` + Environment string `json:"environment"` + SecretKeyCiphertext string `json:"secretKeyCiphertext"` + SecretKeyIV string `json:"secretKeyIV"` + SecretKeyTag string `json:"secretKeyTag"` + SecretValueCiphertext string `json:"secretValueCiphertext"` + SecretValueIV string `json:"secretValueIV"` + SecretValueTag string `json:"secretValueTag"` + SecretCommentCiphertext string `json:"secretCommentCiphertext"` + SecretCommentIV string `json:"secretCommentIV"` + SecretCommentTag string `json:"secretCommentTag"` + Algorithm string `json:"algorithm"` + KeyEncoding string `json:"keyEncoding"` + Folder string `json:"folder"` + V int `json:"__v"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ImportedSecretV3 struct { + Environment string `json:"environment"` + FolderId string `json:"folderId"` + SecretPath string `json:"secretPath"` + Secrets []EncryptedSecretV3 `json:"secrets"` } type GetEncryptedSecretsV3Response struct { - Secrets []struct { - ID string `json:"_id"` - Version int `json:"version"` - Workspace string `json:"workspace"` - Type string `json:"type"` - Tags []struct { - ID string `json:"_id"` - Name string `json:"name"` - Slug string `json:"slug"` - Workspace string `json:"workspace"` - } `json:"tags"` - Environment string `json:"environment"` - SecretKeyCiphertext string `json:"secretKeyCiphertext"` - SecretKeyIV string `json:"secretKeyIV"` - SecretKeyTag string `json:"secretKeyTag"` - SecretValueCiphertext string `json:"secretValueCiphertext"` - SecretValueIV string `json:"secretValueIV"` - SecretValueTag string `json:"secretValueTag"` - SecretCommentCiphertext string `json:"secretCommentCiphertext"` - SecretCommentIV string `json:"secretCommentIV"` - SecretCommentTag string `json:"secretCommentTag"` - Algorithm string `json:"algorithm"` - KeyEncoding string `json:"keyEncoding"` - Folder string `json:"folder"` - V int `json:"__v"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - } `json:"secrets"` + Secrets []EncryptedSecretV3 `json:"secrets"` + ImportedSecrets []ImportedSecretV3 `json:"imports,omitempty"` } type CreateSecretV3Request struct { diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 2303cbe2e..02fe16327 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -87,7 +87,12 @@ var runCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath}) + includeImports, err := cmd.Flags().GetBool("include-imports") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath, IncludeImport: includeImports}) if err != nil { util.HandleError(err, "Could not fetch secrets", "If you are using a service token to fetch secrets, please ensure it is valid") @@ -186,6 +191,7 @@ func init() { runCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") runCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from") runCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") + runCmd.Flags().Bool("include-imports", true, "Import linked secrets ") runCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") runCmd.Flags().StringP("command", "c", "", "chained commands to execute (e.g. \"npm install && npm run dev; echo ...\")") runCmd.Flags().StringP("tags", "t", "", "filter secrets by tag slugs ") diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 688dd55bf..4d55f4307 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -54,12 +54,17 @@ var secretsCmd = &cobra.Command{ util.HandleError(err) } + includeImports, err := cmd.Flags().GetBool("include-imports") + if err != nil { + util.HandleError(err) + } + tagSlugs, err := cmd.Flags().GetString("tags") if err != nil { util.HandleError(err, "Unable to parse flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath}) + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath, IncludeImport: includeImports}) if err != nil { util.HandleError(err) } @@ -647,6 +652,7 @@ func init() { secretsCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") secretsCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") + secretsCmd.Flags().Bool("include-imports", true, "Imported linked secrets ") secretsCmd.PersistentFlags().StringP("tags", "t", "", "filter secrets by tag slugs") secretsCmd.Flags().String("path", "/", "get secrets within a folder path") rootCmd.AddCommand(secretsCmd) diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 18a64750f..c388d2d9e 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -65,4 +65,5 @@ type GetAllSecretsParameters struct { TagSlugs string WorkspaceId string SecretsPath string + IncludeImport bool } diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index d49e92607..cde0a2efb 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -17,7 +17,7 @@ import ( "github.com/rs/zerolog/log" ) -func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment string, secretPath string) ([]models.SingleEnvironmentVariable, api.GetServiceTokenDetailsResponse, error) { +func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment string, secretPath string, includeImports bool) ([]models.SingleEnvironmentVariable, api.GetServiceTokenDetailsResponse, error) { serviceTokenParts := strings.SplitN(fullServiceToken, ".", 4) if len(serviceTokenParts) < 4 { return nil, api.GetServiceTokenDetailsResponse{}, fmt.Errorf("invalid service token entered. Please double check your service token and try again") @@ -45,9 +45,10 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str } encryptedSecrets, err := api.CallGetSecretsV3(httpClient, api.GetEncryptedSecretsV3Request{ - WorkspaceId: serviceTokenDetails.Workspace, - Environment: environment, - SecretPath: secretPath, + WorkspaceId: serviceTokenDetails.Workspace, + Environment: environment, + SecretPath: secretPath, + IncludeImport: includeImports, }) if err != nil { @@ -64,15 +65,22 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str return nil, api.GetServiceTokenDetailsResponse{}, fmt.Errorf("unable to decrypt the required workspace key") } - plainTextSecrets, err := GetPlainTextSecrets(plainTextWorkspaceKey, encryptedSecrets) + plainTextSecrets, err := GetPlainTextSecrets(plainTextWorkspaceKey, encryptedSecrets.Secrets) if err != nil { return nil, api.GetServiceTokenDetailsResponse{}, fmt.Errorf("unable to decrypt your secrets [err=%v]", err) } + if includeImports { + plainTextSecrets, err = InjectImportedSecret(plainTextWorkspaceKey, plainTextSecrets, encryptedSecrets.ImportedSecrets) + if err != nil { + return nil, api.GetServiceTokenDetailsResponse{}, err + } + } + return plainTextSecrets, serviceTokenDetails, nil } -func GetPlainTextSecretsViaJTW(JTWToken string, receiversPrivateKey string, workspaceId string, environmentName string, tagSlugs string, secretsPath string) ([]models.SingleEnvironmentVariable, error) { +func GetPlainTextSecretsViaJTW(JTWToken string, receiversPrivateKey string, workspaceId string, environmentName string, tagSlugs string, secretsPath string, includeImports bool) ([]models.SingleEnvironmentVariable, error) { httpClient := resty.New() httpClient.SetAuthToken(JTWToken). SetHeader("Accept", "application/json") @@ -114,8 +122,9 @@ func GetPlainTextSecretsViaJTW(JTWToken string, receiversPrivateKey string, work plainTextWorkspaceKey := crypto.DecryptAsymmetric(encryptedWorkspaceKey, encryptedWorkspaceKeyNonce, encryptedWorkspaceKeySenderPublicKey, currentUsersPrivateKey) getSecretsRequest := api.GetEncryptedSecretsV3Request{ - WorkspaceId: workspaceId, - Environment: environmentName, + WorkspaceId: workspaceId, + Environment: environmentName, + IncludeImport: includeImports, // TagSlugs: tagSlugs, } @@ -124,19 +133,53 @@ func GetPlainTextSecretsViaJTW(JTWToken string, receiversPrivateKey string, work } encryptedSecrets, err := api.CallGetSecretsV3(httpClient, getSecretsRequest) - if err != nil { return nil, err } - plainTextSecrets, err := GetPlainTextSecrets(plainTextWorkspaceKey, encryptedSecrets) + plainTextSecrets, err := GetPlainTextSecrets(plainTextWorkspaceKey, encryptedSecrets.Secrets) if err != nil { return nil, fmt.Errorf("unable to decrypt your secrets [err=%v]", err) } + if includeImports { + plainTextSecrets, err = InjectImportedSecret(plainTextWorkspaceKey, plainTextSecrets, encryptedSecrets.ImportedSecrets) + if err != nil { + return nil, err + } + } + return plainTextSecrets, nil } +func InjectImportedSecret(plainTextWorkspaceKey []byte, secrets []models.SingleEnvironmentVariable, importedSecrets []api.ImportedSecretV3) ([]models.SingleEnvironmentVariable, error) { + if importedSecrets == nil { + return secrets, nil + } + + hasOverriden := make(map[string]bool) + for _, sec := range secrets { + hasOverriden[sec.Key] = true + } + + for i := len(importedSecrets) - 1; i >= 0; i-- { + importSec := importedSecrets[i] + plainTextImportedSecrets, err := GetPlainTextSecrets(plainTextWorkspaceKey, importSec.Secrets) + + if err != nil { + return nil, fmt.Errorf("unable to decrypt your imported secrets [err=%v]", err) + } + + for _, sec := range plainTextImportedSecrets { + if _, ok := hasOverriden[sec.Key]; !ok { + secrets = append(secrets, sec) + hasOverriden[sec.Key] = true + } + } + } + return secrets, nil +} + func GetAllEnvironmentVariables(params models.GetAllSecretsParameters) ([]models.SingleEnvironmentVariable, error) { var infisicalToken string if params.InfisicalToken == "" { @@ -179,7 +222,8 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters) ([]models return nil, fmt.Errorf("unable to validate environment name because [err=%s]", err) } - secretsToReturn, errorToReturn = GetPlainTextSecretsViaJTW(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceFile.WorkspaceId, params.Environment, params.TagSlugs, params.SecretsPath) + secretsToReturn, errorToReturn = GetPlainTextSecretsViaJTW(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceFile.WorkspaceId, + params.Environment, params.TagSlugs, params.SecretsPath, params.IncludeImport) log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", errorToReturn) backupSecretsEncryptionKey := []byte(loggedInUserDetails.UserCredentials.PrivateKey)[0:32] @@ -199,7 +243,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters) ([]models } else { log.Debug().Msg("Trying to fetch secrets using service token") - secretsToReturn, _, errorToReturn = GetPlainTextSecretsViaServiceToken(infisicalToken, params.Environment, params.SecretsPath) + secretsToReturn, _, errorToReturn = GetPlainTextSecretsViaServiceToken(infisicalToken, params.Environment, params.SecretsPath, params.IncludeImport) } return secretsToReturn, errorToReturn @@ -427,9 +471,9 @@ func OverrideSecrets(secrets []models.SingleEnvironmentVariable, secretType stri return secretsToReturn } -func GetPlainTextSecrets(key []byte, encryptedSecrets api.GetEncryptedSecretsV3Response) ([]models.SingleEnvironmentVariable, error) { +func GetPlainTextSecrets(key []byte, encryptedSecrets []api.EncryptedSecretV3) ([]models.SingleEnvironmentVariable, error) { plainTextSecrets := []models.SingleEnvironmentVariable{} - for _, secret := range encryptedSecrets.Secrets { + for _, secret := range encryptedSecrets { // Decrypt key key_iv, err := base64.StdEncoding.DecodeString(secret.SecretKeyIV) if err != nil { diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index ce9b50db5..8df6fce72 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -1,5 +1,5 @@ --- -title: "Reference Secrets" +title: "Reference/Import Secrets" description: "How to use reference secrets in Infisical" --- @@ -34,4 +34,16 @@ For instance, to access a secret 'A' composed of secrets 'B' and 'C' from differ When using [service tokens](./token) to fetch referenced secrets, ensure the service token has read access to all referenced environments and folders. Without proper permissions, the final secret value may be incomplete. +## Import multiple secrets from a environment secret path +You can import or link another environment's folder to your dashboard to inherit its secrets. This is useful when you need to share secrets across multiple environments. + +To add an import/link, just click on `Secret Link` button and provide an `environment` and `secret path` to which the secrets must be pulled. + +![secret import change order](../../images/secret-import-add.png) + +The import hierarchy is "last one wins." This means that the order in which you import matters. The last folder you import will override the secrets from any previous folders. Additionally, any secrets you define in your dashboard will override the secrets from any imported folders. + +You can change the order by dragging and positioning according using the `Change Order` drag handle. + +![secret import change order](../../images/secret-import-change-order.png diff --git a/docs/images/secret-import-add.png b/docs/images/secret-import-add.png new file mode 100644 index 000000000..1376fd412 Binary files /dev/null and b/docs/images/secret-import-add.png differ diff --git a/docs/images/secret-import-change-order.png b/docs/images/secret-import-change-order.png new file mode 100644 index 000000000..792958d93 Binary files /dev/null and b/docs/images/secret-import-change-order.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb3360485..5853ff650 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5,6 +5,9 @@ "packages": { "": { "dependencies": { + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", "@emotion/css": "^11.10.0", "@emotion/server": "^11.10.0", "@fontsource/inter": "^4.5.15", @@ -2468,6 +2471,68 @@ "node": ">=10.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", + "integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", + "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", + "dependencies": { + "@dnd-kit/accessibility": "^3.0.0", + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz", + "integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.0.6", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.0", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.0.7", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz", + "integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", @@ -24491,6 +24556,50 @@ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", "dev": true }, + "@dnd-kit/accessibility": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", + "integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@dnd-kit/core": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", + "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", + "requires": { + "@dnd-kit/accessibility": "^3.0.0", + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + } + }, + "@dnd-kit/modifiers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz", + "integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==", + "requires": { + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + } + }, + "@dnd-kit/sortable": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "requires": { + "@dnd-kit/utilities": "^3.2.0", + "tslib": "^2.0.0" + } + }, + "@dnd-kit/utilities": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz", + "integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==", + "requires": { + "tslib": "^2.0.0" + } + }, "@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f20477e6f..0430844e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,9 @@ "build-storybook": "storybook build" }, "dependencies": { + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", "@emotion/css": "^11.10.0", "@emotion/server": "^11.10.0", "@fontsource/inter": "^4.5.15", diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 299629416..18e5b59fb 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -6,6 +6,7 @@ export * from "./integrations"; export * from "./keys"; export * from "./organization"; export * from "./secretFolders"; +export * from "./secretImports"; export * from "./secrets"; export * from "./secretSnapshots"; export * from "./serviceAccounts"; diff --git a/frontend/src/hooks/api/secretImports/index.ts b/frontend/src/hooks/api/secretImports/index.ts new file mode 100644 index 000000000..2e1150cb8 --- /dev/null +++ b/frontend/src/hooks/api/secretImports/index.ts @@ -0,0 +1,2 @@ +export { useCreateSecretImport, useDeleteSecretImport, useUpdateSecretImport } from "./mutation"; +export { useGetImportedSecrets, useGetSecretImports } from "./queries"; diff --git a/frontend/src/hooks/api/secretImports/mutation.tsx b/frontend/src/hooks/api/secretImports/mutation.tsx new file mode 100644 index 000000000..bb01528ea --- /dev/null +++ b/frontend/src/hooks/api/secretImports/mutation.tsx @@ -0,0 +1,78 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { secretImportKeys } from "./queries"; +import { TCreateSecretImportDTO, TDeleteSecretImportDTO, TUpdateSecretImportDTO } from "./types"; + +export const useCreateSecretImport = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateSecretImportDTO>({ + mutationFn: async ({ secretImport, environment, workspaceId, folderId }) => { + const { data } = await apiRequest.post("/api/v1/secret-imports", { + secretImport, + environment, + workspaceId, + folderId + }); + return data; + }, + onSuccess: (_, { workspaceId, environment, folderId }) => { + queryClient.invalidateQueries( + secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId) + ); + queryClient.invalidateQueries( + secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId) + ); + } + }); +}; + +export const useUpdateSecretImport = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSecretImportDTO>({ + mutationFn: async ({ environment, workspaceId, folderId, secretImports, id }) => { + const { data } = await apiRequest.put(`/api/v1/secret-imports/${id}`, { + secretImports, + environment, + workspaceId, + folderId + }); + return data; + }, + onSuccess: (_, { workspaceId, environment, folderId }) => { + queryClient.invalidateQueries( + secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId) + ); + queryClient.invalidateQueries( + secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId) + ); + } + }); +}; + +export const useDeleteSecretImport = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSecretImportDTO>({ + mutationFn: async ({ id, secretImportEnv, secretImportPath }) => { + const { data } = await apiRequest.delete(`/api/v1/secret-imports/${id}`, { + data: { + secretImportPath, + secretImportEnv + } + }); + return data; + }, + onSuccess: (_, { workspaceId, environment, folderId }) => { + queryClient.invalidateQueries( + secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId) + ); + queryClient.invalidateQueries( + secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId) + ); + } + }); +}; diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx new file mode 100644 index 000000000..7bb07488c --- /dev/null +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -0,0 +1,124 @@ +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { + decryptAssymmetric, + decryptSymmetric +} from "@app/components/utilities/cryptography/crypto"; +import { apiRequest } from "@app/config/request"; + +import { TGetImportedSecrets, 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" + ] +}; + +const fetchSecretImport = async (workspaceId: string, environment: string, folderId?: string) => { + const { data } = await apiRequest.get<{ secretImport: TSecretImports }>( + "/api/v1/secret-imports", + { + params: { + workspaceId, + environment, + folderId + } + } + ); + return data.secretImport; +}; + +export const useGetSecretImports = (workspaceId: string, env: string, folderId?: string) => + useQuery({ + enabled: Boolean(workspaceId) && Boolean(env), + queryKey: secretImportKeys.getProjectSecretImports(workspaceId, env, folderId), + queryFn: () => fetchSecretImport(workspaceId, env, folderId) + }); + +const fetchImportedSecrets = async ( + workspaceId: string, + environment: string, + folderId?: string +) => { + const { data } = await apiRequest.get<{ secrets: TImportedSecrets }>( + "/api/v1/secret-imports/secrets", + { + params: { + workspaceId, + environment, + folderId + } + } + ); + return data.secrets; +}; + +export const useGetImportedSecrets = ({ + workspaceId, + environment, + folderId, + decryptFileKey +}: TGetImportedSecrets) => + useQuery({ + enabled: Boolean(workspaceId) && Boolean(environment) && Boolean(decryptFileKey), + queryKey: secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId), + queryFn: () => fetchImportedSecrets(workspaceId, environment, folderId), + select: useCallback( + (data: TImportedSecrets) => { + 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 + }); + + return data.map((el) => ({ + environment: el.environment, + secretPath: el.secretPath, + folderId: el.folderId, + secrets: el.secrets.map((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 + }); + + return { + _id: encSecret._id, + env: encSecret.environment, + key: secretKey, + value: secretValue, + tags: encSecret.tags, + comment: secretComment, + createdAt: encSecret.createdAt, + updatedAt: encSecret.updatedAt + }; + }) + })); + }, + [decryptFileKey] + ) + }); diff --git a/frontend/src/hooks/api/secretImports/types.ts b/frontend/src/hooks/api/secretImports/types.ts new file mode 100644 index 000000000..50884225f --- /dev/null +++ b/frontend/src/hooks/api/secretImports/types.ts @@ -0,0 +1,56 @@ +import { EncryptedSecret } from "../secrets/types"; +import { UserWsKeyPair } from "../types"; + +export type TSecretImports = { + _id: string; + workspaceId: string; + environment: string; + folderId: string; + imports: Array<{ environment: string; secretPath: string }>; + createdAt: string; + updatedAt: string; +}; + +export type TImportedSecrets = { + environment: string; + secretPath: string; + folderId: string; + secrets: EncryptedSecret[]; +}[]; + +export type TGetImportedSecrets = { + workspaceId: string; + environment: string; + folderId?: string; + decryptFileKey: UserWsKeyPair; +}; + +export type TCreateSecretImportDTO = { + workspaceId: string; + environment: string; + folderId?: string; + secretImport: { + environment: string; + secretPath: string; + }; +}; + +export type TUpdateSecretImportDTO = { + id: string; + workspaceId: string; + environment: string; + folderId?: string; + secretImports: Array<{ + environment: string; + secretPath: string; + }>; +}; + +export type TDeleteSecretImportDTO = { + id: string; + workspaceId: string; + environment: string; + folderId?: string; + secretImportPath: string; + secretImportEnv: string; +}; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 397f68aa7..1541ff753 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -24,6 +24,10 @@ export const secretKeys = { { workspaceId, env, folderId }, "secrets" ], + getProjectSecretImports: (workspaceId: string, env: string | string[], folderId?: string) => [ + { workspaceId, env, folderId }, + "secrets-imports" + ], getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] }; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index a622b25eb..b36eb47f7 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -34,6 +34,7 @@ export type DecryptedSecret = { valueOverride?: string; idOverride?: string; overrideAction?: string; + folderId?: string; }; export type EncryptedSecretVersion = { @@ -98,6 +99,7 @@ export type GetProjectSecretsDTO = { folderId?: string; secretPath?: string; isPaused?: boolean; + include_imports?: boolean; onSuccess?: (data: DecryptedSecret[]) => void; }; diff --git a/frontend/src/views/DashboardPage/DashboardPage.tsx b/frontend/src/views/DashboardPage/DashboardPage.tsx index 1cb686414..fbbb66053 100644 --- a/frontend/src/views/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage/DashboardPage.tsx @@ -3,6 +3,19 @@ import { FormProvider, useFieldArray, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; 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, @@ -10,12 +23,14 @@ import { 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"; @@ -41,10 +56,14 @@ import { useLeaveConfirm, usePopUp, useToggle } from "@app/hooks"; import { useBatchSecretsOp, useCreateFolder, + useCreateSecretImport, useCreateWsTag, useDeleteFolder, + useDeleteSecretImport, + useGetImportedSecrets, useGetProjectFolders, useGetProjectSecrets, + useGetSecretImports, useGetSecretVersion, useGetSnapshotSecrets, useGetUserAction, @@ -55,7 +74,8 @@ import { useGetWsTags, usePerformSecretRollback, useRegisterUserAction, - useUpdateFolder + useUpdateFolder, + useUpdateSecretImport } from "@app/hooks/api"; import { secretKeys } from "@app/hooks/api/secrets/queries"; import { WorkspaceEnv } from "@app/hooks/api/types"; @@ -71,6 +91,8 @@ import { 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 { @@ -84,7 +106,7 @@ import { } 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 @@ -113,7 +135,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { "compareSecrets", "folderForm", "deleteFolder", - "upgradePlan" + "upgradePlan", + "addSecretImport", + "deleteSecretImport" ] as const); const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true); const [searchFilter, setSearchFilter] = useState(""); @@ -129,6 +153,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { const { currentWorkspace, isLoading } = useWorkspace(); const { currentOrg } = useOrganization(); const workspaceId = currentWorkspace?._id as string; + const selectedEnvSlug = selectedEnv?.slug || ""; + const { data: latestFileKey } = useGetUserWsKey(workspaceId); useEffect(() => { @@ -161,7 +187,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ workspaceId, - env: selectedEnv?.slug || "", + env: selectedEnvSlug, decryptFileKey: latestFileKey!, isPaused: Boolean(snapshotId), folderId @@ -169,7 +195,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({ workspaceId: workspaceId || "", - environment: selectedEnv?.slug || "", + environment: selectedEnvSlug, parentFolderId: folderId, isPaused: isRollbackMode, sortDir @@ -182,7 +208,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { isFetchingNextPage } = useGetWorkspaceSecretSnapshots({ workspaceId, - environment: selectedEnv?.slug || "", + environment: selectedEnvSlug, folder: folderId, limit: 10 }); @@ -193,17 +219,18 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { isFetching: isSnapshotChanging } = useGetSnapshotSecrets({ snapshotId: snapshotId || "", - env: selectedEnv?.slug || "", + env: selectedEnvSlug, decryptFileKey: latestFileKey! }); const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount( workspaceId, - selectedEnv?.slug || "", + selectedEnvSlug, folderId ); const { data: wsTags } = useGetWsTags(workspaceId); + // mutation calls const { mutateAsync: batchSecretOp } = useBatchSecretsOp(); const { mutateAsync: performSecretRollback } = usePerformSecretRollback(); @@ -213,6 +240,50 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { const { mutateAsync: updateFolder } = useUpdateFolder(folderId); const { mutateAsync: deleteFolder } = useDeleteFolder(folderId); + const { data: secretImportCfg, isFetching: isSecretImportCfgFetching } = useGetSecretImports( + workspaceId, + selectedEnvSlug, + folderId + ); + + const { data: importedSecrets } = useGetImportedSecrets({ + workspaceId, + decryptFileKey: latestFileKey!, + environment: selectedEnvSlug, + folderId + }); + + // 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, @@ -319,14 +390,12 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { await performSecretRollback({ workspaceId, version: snapshotSecret.version, - environment: selectedEnv?.slug || "", + environment: selectedEnvSlug, folderId }); setValue("isSnapshotMode", false); setSnaphotId(null); - queryClient.invalidateQueries( - secretKeys.getProjectSecret(workspaceId, selectedEnv?.slug || "") - ); + queryClient.invalidateQueries(secretKeys.getProjectSecret(workspaceId, selectedEnvSlug)); createNotification({ text: "Successfully rollback secrets", type: "success" @@ -522,6 +591,79 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { } }; + const handleSecretImportCreate = async (env: string, secretPath: string) => { + try { + await createSecretImport({ + workspaceId, + environment: selectedEnv?.slug || "", + folderId, + secretImport: { + environment: env, + secretPath + } + }); + 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: selectedEnvSlug, + 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: selectedEnvSlug, + folderId, + id: secretImportCfg?._id || "", + secretImports: newImportOrder.map((el) => ({ + environment: el.environment, + secretPath: el.secretPath + })) + }); + } + }; + // when secrets is not loading and secrets list is empty const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length; @@ -534,7 +676,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { const isSnapshotSecretEmtpy = isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length; const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy; - const isEmptyPage = isFoldersEmpty && isSecretEmpty; + const isSecretImportEmpty = !importedSecrets?.length; + const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty; if (isSecretsLoading || isEnvListLoading) { return ( @@ -652,54 +795,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { {!isReadOnly && !isRollbackMode && ( - <> -
- - handlePopUpOpen("folderForm")} - > - - - -
- -
- - { - if (secretContainer.current) { - secretContainer.current.scroll({ - top: 0, - behavior: "smooth" - }); - } - prepend(DEFAULT_SECRET_VALUE, { shouldFocus: false }); - }} - > - - - -
-
- -
- -
- -
- + } + }} + className="font-semibold bg-mineshaft-600 border border-mineshaft-500 p-2 rounded-l-md text-sm text-mineshaft-300 cursor-pointer hover:bg-primary/[0.1] hover:border-primary/40 pr-4 duration-200" + > + Add Secret + + + +
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ )} {isRollbackMode && ( + + + )} + + + + )} { /> + handlePopUpToggle("addSecretImport", isOpen)} + > + + + + { onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)} onDeleteApproved={handleFolderDelete} /> + handlePopUpToggle("deleteSecretImport", isOpen)} + onDeleteApproved={handleSecretImportDelete} + /> handlePopUpToggle("compareSecrets", open)} diff --git a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx b/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx index 482324246..dbdf57fe0 100644 --- a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx +++ b/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx @@ -67,7 +67,7 @@ export const FolderSection = ({ ariaLabel="delete" onClick={() => handleFolderDelete(id, name)} > - + diff --git a/frontend/src/views/DashboardPage/components/SecretImportForm/SecretImportForm.tsx b/frontend/src/views/DashboardPage/components/SecretImportForm/SecretImportForm.tsx new file mode 100644 index 000000000..27adbf24f --- /dev/null +++ b/frontend/src/views/DashboardPage/components/SecretImportForm/SecretImportForm.tsx @@ -0,0 +1,86 @@ +import { Controller, useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2"; + +type Props = { + onCreate: (environment: string, secretPath: string) => Promise; + environments?: Array<{ slug: string; name: string }>; +}; + +const formSchema = yup.object({ + environment: yup.string().required().label("Environment").trim(), + secretPath: yup + .string() + .required() + .label("Secret Path") + .trim() + .transform((val) => + typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val + ) +}); + +type TFormData = yup.InferType; + +export const SecretImportForm = ({ onCreate, environments = [] }: Props): JSX.Element => { + const { + control, + reset, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: yupResolver(formSchema) + }); + + const onSubmit = async ({ environment, secretPath }: TFormData) => { + await onCreate(environment, secretPath); + reset(); + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + + + +
+ + ); +}; diff --git a/frontend/src/views/DashboardPage/components/SecretImportForm/index.tsx b/frontend/src/views/DashboardPage/components/SecretImportForm/index.tsx new file mode 100644 index 000000000..c5be50814 --- /dev/null +++ b/frontend/src/views/DashboardPage/components/SecretImportForm/index.tsx @@ -0,0 +1 @@ +export { SecretImportForm } from "./SecretImportForm"; diff --git a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx new file mode 100644 index 000000000..6e424fc1a --- /dev/null +++ b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx @@ -0,0 +1,144 @@ +import { useEffect } from "react"; +import { useSortable } from "@dnd-kit/sortable"; +import { faFileImport, faFolder, faUpDown, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, TableContainer, Tooltip } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useToggle } from "@app/hooks/useToggle"; + +type Props = { + onDelete: (environment: string, secretPath: string) => void; + importedEnv: string; + importedSecPath: string; + importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[]; +}; + +// to show the environment and folder icon +export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => ( +
+
{env || "-"}
+ {secretPath && ( +
+ + {secretPath} +
+ )} +
+); + +export const SecretImportItem = ({ + importedEnv, + importedSecPath, + onDelete, + importedSecrets = [] +}: Props) => { + const [isExpanded, setIsExpanded] = useToggle(); + const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({ + id: `${importedEnv}-${importedSecPath}` + }); + const { currentWorkspace } = useWorkspace(); + const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv); + + useEffect(() => { + if (isDragging) { + setIsExpanded.off(); + } + }, [isDragging]); + + const style = { + transform: transform ? `translateY(${transform.y ? Math.round(transform.y) : 0}px)` : "", + transition + }; + + return ( + <> + setIsExpanded.toggle()} + > + + + + +
+ +
+
+
+ + + + + +
+
+ + { + evt.stopPropagation(); + onDelete(importedEnv, importedSecPath); + }} + > + + + +
+
+ + + + {isExpanded && !isDragging && ( + +
+
Secrets Imported
+ + + + + + + + + + + {importedSecrets.map(({ key, value, overriden }, index) => ( + + + + + + ))} + +
KeyValueOverride
+ {key} + + {value} + + +
+
+
+ + )} + + + ); +}; diff --git a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx new file mode 100644 index 000000000..6998b6ae3 --- /dev/null +++ b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx @@ -0,0 +1,93 @@ +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; + +import { useWorkspace } from "@app/context"; +import { DecryptedSecret } from "@app/hooks/api/secrets/types"; + +import { SecretImportItem } from "./SecretImportItem"; + +type TImportedSecrets = Array<{ + environment: string; + secretPath: string; + folderId: string; + secrets: DecryptedSecret[]; +}>; + +const SECRET_IN_DASHBOARD = "Present In Dashboard"; + +export const computeImportedSecretRows = ( + importedSecEnv: string, + importedSecPath: string, + importSecrets: TImportedSecrets = [], + secrets: DecryptedSecret[] = [], + environments: { name: string; slug: string }[] = [] +) => { + const importedSecIndex = importSecrets.findIndex( + ({ secretPath, environment }) => + secretPath === importedSecPath && importedSecEnv === environment + ); + if (importedSecIndex === -1) return []; + + const importedSec = importSecrets[importedSecIndex]; + + const overridenSec: Record = {}; + const envSlug2Name: Record = {}; + environments.forEach((el) => { + envSlug2Name[el.slug] = el.name; + }); + + for (let i = importedSecIndex + 1; i < importSecrets.length; i += 1) { + importSecrets[i].secrets.forEach((el) => { + overridenSec[el.key] = { + env: envSlug2Name?.[importSecrets[i].environment] || "unknown", + secretPath: importSecrets[i].secretPath + }; + }); + } + + secrets.forEach((el) => { + overridenSec[el.key] = { env: SECRET_IN_DASHBOARD, secretPath: "" }; + }); + + return importedSec.secrets.map(({ key, value }) => ({ + key, + value, + overriden: overridenSec?.[key] + })); +}; + +type Props = { + secrets?: DecryptedSecret[]; + importedSecrets?: TImportedSecrets; + onSecretImportDelete: (env: string, secPath: string) => void; + items: { id: string; environment: string; secretPath: string }[]; +}; + +export const SecretImportSection = ({ + secrets = [], + importedSecrets = [], + onSecretImportDelete, + items = [] +}: Props) => { + const { currentWorkspace } = useWorkspace(); + const environments = currentWorkspace?.environments || []; + + return ( + + {items.map(({ secretPath: impSecPath, environment: importSecEnv, id }) => ( + + ))} + + ); +}; diff --git a/frontend/src/views/DashboardPage/components/SecretImportSection/index.tsx b/frontend/src/views/DashboardPage/components/SecretImportSection/index.tsx new file mode 100644 index 000000000..837c9ce6d --- /dev/null +++ b/frontend/src/views/DashboardPage/components/SecretImportSection/index.tsx @@ -0,0 +1 @@ +export { SecretImportSection } from "./SecretImportSection"; diff --git a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx index fd79785d3..2a99400fb 100644 --- a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx +++ b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx @@ -407,7 +407,7 @@ export const SecretInputRow = memo(