diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index b2c28892d..aff592845 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -9,7 +9,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { OrderByDirection } from "@app/lib/types"; +import { OrderByDirection, ProjectServiceActor } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; @@ -22,6 +22,7 @@ import { TDeleteDynamicSecretDTO, TDetailsDynamicSecretDTO, TGetDynamicSecretsCountDTO, + TListDynamicSecretsByFolderMappingsDTO, TListDynamicSecretsDTO, TListDynamicSecretsMultiEnvDTO, TUpdateDynamicSecretDTO @@ -454,8 +455,44 @@ export const dynamicSecretServiceFactory = ({ return dynamicSecretCfg; }; + const listDynamicSecretsByFolderIds = async ( + { folderMappings, filters, projectId }: TListDynamicSecretsByFolderMappingsDTO, + actor: ProjectServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission( + actor.type, + actor.id, + projectId, + actor.authMethod, + actor.orgId + ); + + const userAccessibleFolderMappings = folderMappings.filter(({ path, environment }) => + permission.can( + ProjectPermissionDynamicSecretActions.ReadRootCredential, + subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath: path }) + ) + ); + + const groupedFolderMappings = new Map(userAccessibleFolderMappings.map((path) => [path.folderId, path])); + + const dynamicSecrets = await dynamicSecretDAL.listDynamicSecretsByFolderIds({ + folderIds: userAccessibleFolderMappings.map(({ folderId }) => folderId), + ...filters + }); + + return dynamicSecrets.map((dynamicSecret) => { + const { environment, path } = groupedFolderMappings.get(dynamicSecret.folderId)!; + return { + ...dynamicSecret, + environment, + path + }; + }); + }; + // get dynamic secrets for multiple envs - const listDynamicSecretsByFolderIds = async ({ + const listDynamicSecretsByEnvs = async ({ actorAuthMethod, actorOrgId, actorId, @@ -521,9 +558,10 @@ export const dynamicSecretServiceFactory = ({ deleteByName, getDetails, listDynamicSecretsByEnv, - listDynamicSecretsByFolderIds, + listDynamicSecretsByEnvs, getDynamicSecretCount, getCountMultiEnv, - fetchAzureEntraIdUsers + fetchAzureEntraIdUsers, + listDynamicSecretsByFolderIds }; }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts index 208290db1..957d884c8 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts @@ -48,17 +48,27 @@ export type TDetailsDynamicSecretDTO = { projectSlug: string; } & Omit; -export type TListDynamicSecretsDTO = { - path: string; - environmentSlug: string; - projectSlug?: string; - projectId?: string; +export type ListDynamicSecretsFilters = { offset?: number; limit?: number; orderBy?: SecretsOrderBy; orderDirection?: OrderByDirection; search?: string; -} & Omit; +}; + +export type TListDynamicSecretsDTO = { + path: string; + environmentSlug: string; + projectSlug?: string; + projectId?: string; +} & ListDynamicSecretsFilters & + Omit; + +export type TListDynamicSecretsByFolderMappingsDTO = { + projectId: string; + folderMappings: { folderId: string; path: string; environment: string }[]; + filters: ListDynamicSecretsFilters; +}; export type TListDynamicSecretsMultiEnvDTO = Omit< TListDynamicSecretsDTO, diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index 604ec3dd5..cb26021c4 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -57,3 +57,10 @@ export enum OrderByDirection { ASC = "asc", DESC = "desc" } + +export type ProjectServiceActor = { + type: ActorType; + id: string; + authMethod: ActorAuthMethod; + orgId: string; +}; diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 36f0df591..8213cf666 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -20,6 +20,8 @@ import { AuthMode } from "@app/services/auth/auth-type"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; +const MAX_DEEP_SEARCH_LIMIT = 500; // arbitrary limit to prevent excessive results + // handle querystring boolean values const booleanSchema = z .union([z.boolean(), z.string().trim()]) @@ -34,6 +36,35 @@ const booleanSchema = z .optional() .default(true); +const parseSecretPathSearch = (search?: string) => { + if (!search) + return { + searchName: "", + searchPath: "" + }; + + if (!search.includes("/")) + return { + searchName: search, + searchPath: "" + }; + + if (search === "/") + return { + searchName: "", + searchPath: "/" + }; + + const [searchName, ...searchPathSegments] = search.split("/").reverse(); + let searchPath = removeTrailingSlash(searchPathSegments.reverse().join("/").toLowerCase()); + if (!searchPath.startsWith("/")) searchPath = `/${searchPath}`; + + return { + searchName, + searchPath + }; +}; + export const registerDashboardRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", @@ -134,7 +165,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let folders: Awaited> | undefined; let secrets: Awaited> | undefined; let dynamicSecrets: - | Awaited> + | Awaited> | undefined; let totalFolderCount: number | undefined; @@ -218,7 +249,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }); if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) { - dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByFolderIds({ + dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByEnvs({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -633,4 +664,180 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "GET", + url: "/secrets-deep-search", + config: { + rateLimit: secretsLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string().trim(), + environments: z.string().trim().transform(decodeURIComponent), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash), + search: z.string().trim().optional(), + tags: z.string().trim().transform(decodeURIComponent).optional() + }), + response: { + 200: z.object({ + folders: SecretFoldersSchema.extend({ path: z.string() }).array().optional(), + dynamicSecrets: SanitizedDynamicSecretSchema.extend({ path: z.string(), environment: z.string() }) + .array() + .optional(), + secrets: secretRawSchema + .extend({ + secretPath: z.string().optional(), + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + color: true + }) + .extend({ name: z.string() }) + .array() + .optional() + }) + .array() + .optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { secretPath, projectId, search } = req.query; + + const environments = req.query.environments.split(",").filter((env) => Boolean(env.trim())); + if (!environments.length) throw new BadRequestError({ message: "One or more environments required" }); + + const tags = req.query.tags?.split(",").filter((tag) => Boolean(tag.trim())) ?? []; + if (!search && !tags.length) throw new BadRequestError({ message: "Search or tags required" }); + + const searchHasTags = Boolean(tags.length); + + const allFolders = await server.services.folder.getFoldersDeepByEnvs( + { + projectId, + environments, + secretPath + }, + req.permission + ); + + const { searchName, searchPath } = parseSecretPathSearch(search); + + const folderMappings = allFolders.map((folder) => ({ + folderId: folder.id, + path: folder.path, + environment: folder.environment + })); + + const sharedFilters = { + search: searchName, + limit: MAX_DEEP_SEARCH_LIMIT, + orderBy: SecretsOrderBy.Name + }; + + const secrets = await server.services.secret.getSecretsRawByFolderMappings( + { + projectId, + folderMappings, + filters: { + ...sharedFilters, + tagSlugs: tags, + includeTagsInSearch: true + } + }, + req.permission + ); + + const dynamicSecrets = searchHasTags + ? [] + : await server.services.dynamicSecret.listDynamicSecretsByFolderIds( + { + projectId, + folderMappings, + filters: sharedFilters + }, + req.permission + ); + + for await (const environment of environments) { + const secretCountForEnv = secrets.filter((secret) => secret.environment === environment).length; + + if (secretCountForEnv) { + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRETS, + metadata: { + environment, + secretPath, + numberOfSecrets: secretCountForEnv + } + } + }); + + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secretCountForEnv, + workspaceId: projectId, + environment, + secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } + } + } + + const sliceQuickSearch = (array: T[]) => array.slice(0, 25); + + return { + secrets: sliceQuickSearch( + searchPath ? secrets.filter((secret) => secret.secretPath.endsWith(searchPath)) : secrets + ), + dynamicSecrets: sliceQuickSearch( + searchPath + ? dynamicSecrets.filter((dynamicSecret) => dynamicSecret.path.endsWith(searchPath)) + : dynamicSecrets + ), + folders: searchHasTags + ? [] + : sliceQuickSearch( + allFolders.filter((folder) => { + const [folderName, ...folderPathSegments] = folder.path.split("/").reverse(); + const folderPath = folderPathSegments.reverse().join("/").toLowerCase() || "/"; + + if (searchPath) { + if (searchPath === "/") { + // only show root folders if no folder name search + if (!searchName) return folderPath === searchPath; + + // start partial match on root folders + return folderName.toLowerCase().startsWith(searchName.toLowerCase()); + } + + // support ending partial path match + return ( + folderPath.endsWith(searchPath) && folderName.toLowerCase().startsWith(searchName.toLowerCase()) + ); + } + + // no search path, "fuzzy" match all folders + return folderName.toLowerCase().includes(searchName.toLowerCase()); + }) + ) + }; + } + }); }; diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index c172f6d3c..adf71febf 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -1,9 +1,9 @@ import { ForbiddenError } from "@casl/ability"; -import { FastifyRequest } from "fastify"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ProjectServiceActor } from "@app/lib/types"; import { TCmekDecryptDTO, TCmekEncryptDTO, @@ -23,7 +23,7 @@ type TCmekServiceFactoryDep = { export type TCmekServiceFactory = ReturnType; export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TCmekServiceFactoryDep) => { - const createCmek = async ({ projectId, ...dto }: TCreateCmekDTO, actor: FastifyRequest["permission"]) => { + const createCmek = async ({ projectId, ...dto }: TCreateCmekDTO, actor: ProjectServiceActor) => { const { permission } = await permissionService.getProjectPermission( actor.type, actor.id, @@ -43,7 +43,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC return cmek; }; - const updateCmekById = async ({ keyId, ...data }: TUpdabteCmekByIdDTO, actor: FastifyRequest["permission"]) => { + const updateCmekById = async ({ keyId, ...data }: TUpdabteCmekByIdDTO, actor: ProjectServiceActor) => { const key = await kmsDAL.findById(keyId); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); @@ -65,7 +65,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC return cmek; }; - const deleteCmekById = async (keyId: string, actor: FastifyRequest["permission"]) => { + const deleteCmekById = async (keyId: string, actor: ProjectServiceActor) => { const key = await kmsDAL.findById(keyId); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); @@ -89,7 +89,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC const listCmeksByProjectId = async ( { projectId, ...filters }: TListCmeksByProjectIdDTO, - actor: FastifyRequest["permission"] + actor: ProjectServiceActor ) => { const { permission } = await permissionService.getProjectPermission( actor.type, @@ -106,7 +106,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC return { cmeks, totalCount }; }; - const cmekEncrypt = async ({ keyId, plaintext }: TCmekEncryptDTO, actor: FastifyRequest["permission"]) => { + const cmekEncrypt = async ({ keyId, plaintext }: TCmekEncryptDTO, actor: ProjectServiceActor) => { const key = await kmsDAL.findById(keyId); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); @@ -132,7 +132,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC return cipherTextBlob.toString("base64"); }; - const cmekDecrypt = async ({ keyId, ciphertext }: TCmekDecryptDTO, actor: FastifyRequest["permission"]) => { + const cmekDecrypt = async ({ keyId, ciphertext }: TCmekDecryptDTO, actor: ProjectServiceActor) => { const key = await kmsDAL.findById(keyId); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); diff --git a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts index 2d116eb38..0b7be1d4f 100644 --- a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts +++ b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts @@ -1,9 +1,9 @@ import { ForbiddenError } from "@casl/ability"; -import { FastifyRequest } from "fastify"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectServiceActor } from "@app/lib/types"; import { constructGroupOrgMembershipRoleMappings } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-fns"; import { TSyncExternalGroupOrgMembershipRoleMappingsDTO } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-types"; import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; @@ -25,7 +25,7 @@ export const externalGroupOrgRoleMappingServiceFactory = ({ permissionService, orgRoleDAL }: TExternalGroupOrgRoleMappingServiceFactoryDep) => { - const listExternalGroupOrgRoleMappings = async (actor: FastifyRequest["permission"]) => { + const listExternalGroupOrgRoleMappings = async (actor: ProjectServiceActor) => { const { permission } = await permissionService.getOrgPermission( actor.type, actor.id, @@ -46,7 +46,7 @@ export const externalGroupOrgRoleMappingServiceFactory = ({ const updateExternalGroupOrgRoleMappings = async ( dto: TSyncExternalGroupOrgMembershipRoleMappingsDTO, - actor: FastifyRequest["permission"] + actor: ProjectServiceActor ) => { const { permission } = await permissionService.getOrgPermission( actor.type, diff --git a/backend/src/services/secret-folder/secret-folder-dal.ts b/backend/src/services/secret-folder/secret-folder-dal.ts index 6365bd824..a4e6aca5a 100644 --- a/backend/src/services/secret-folder/secret-folder-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-dal.ts @@ -8,6 +8,8 @@ import { ormify, selectAllTableCols } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; +import { TFindFoldersDeepByParentIdsDTO } from "./secret-folder-types"; + export const validateFolderName = (folderName: string) => { const validNameRegex = /^[a-zA-Z0-9-_]+$/; return validNameRegex.test(folderName); @@ -444,6 +446,48 @@ export const secretFolderDALFactory = (db: TDbClient) => { } }; + const findByEnvsDeep = async ({ parentIds }: TFindFoldersDeepByParentIdsDTO, tx?: Knex) => { + try { + const folders = await (tx || db.replicaNode()) + .withRecursive("parents", (qb) => + qb + .select( + selectAllTableCols(TableName.SecretFolder), + db.raw("0 as depth"), + db.raw(`'/' as path`), + db.ref(`${TableName.Environment}.slug`).as("environment") + ) + .from(TableName.SecretFolder) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .whereIn(`${TableName.SecretFolder}.id`, parentIds) + .union((un) => { + void un + .select( + selectAllTableCols(TableName.SecretFolder), + db.raw("parents.depth + 1 as depth"), + db.raw( + `CONCAT( + CASE WHEN parents.path = '/' THEN '' ELSE parents.path END, + CASE WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' ELSE CONCAT('/', secret_folders.name) END + )` + ), + db.ref("parents.environment") + ) + .from(TableName.SecretFolder) + .join("parents", `${TableName.SecretFolder}.parentId`, "parents.id"); + }) + ) + .select<(TSecretFolders & { path: string; depth: number; environment: string })[]>("*") + .from("parents") + .orderBy("depth") + .orderBy(`name`); + + return folders; + } catch (error) { + throw new DatabaseError({ error, name: "FindByEnvsDeep" }); + } + }; + return { ...secretFolderOrm, update, @@ -454,6 +498,7 @@ export const secretFolderDALFactory = (db: TDbClient) => { findSecretPathByFolderIds, findClosestFolder, findByProjectId, - findByMultiEnv + findByMultiEnv, + findByEnvsDeep }; }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 91d64b5a2..ae9bfb9cf 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -7,7 +7,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { OrderByDirection } from "@app/lib/types"; +import { OrderByDirection, ProjectServiceActor } from "@app/lib/types"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -17,6 +17,7 @@ import { TDeleteFolderDTO, TGetFolderByIdDTO, TGetFolderDTO, + TGetFoldersDeepByEnvsDTO, TUpdateFolderDTO, TUpdateManyFoldersDTO } from "./secret-folder-types"; @@ -511,6 +512,30 @@ export const secretFolderServiceFactory = ({ }; }; + const getFoldersDeepByEnvs = async ( + { projectId, environments, secretPath }: TGetFoldersDeepByEnvsDTO, + actor: ProjectServiceActor + ) => { + // folder list is allowed to be read by anyone + // permission to check does user have access + await permissionService.getProjectPermission(actor.type, actor.id, projectId, actor.authMethod, actor.orgId); + + const envs = await projectEnvDAL.findBySlugs(projectId, environments); + + if (!envs.length) + throw new NotFoundError({ + message: `Environments '${environments.join(", ")}' not found`, + name: "GetFoldersDeep" + }); + + const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath); + if (!parentFolders.length) return []; + + const folders = await folderDAL.findByEnvsDeep({ parentIds: parentFolders.map((parent) => parent.id) }); + + return folders; + }; + return { createFolder, updateFolder, @@ -519,6 +544,7 @@ export const secretFolderServiceFactory = ({ getFolders, getFolderById, getProjectFolderCount, - getFoldersMultiEnv + getFoldersMultiEnv, + getFoldersDeepByEnvs }; }; diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index a6ddf3688..eb98809cd 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -47,3 +47,13 @@ export type TGetFolderDTO = { export type TGetFolderByIdDTO = { id: string; } & Omit; + +export type TGetFoldersDeepByEnvsDTO = { + projectId: string; + environments: string[]; + secretPath: string; +}; + +export type TFindFoldersDeepByParentIdsDTO = { + parentIds: string[]; +}; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index adbd935ee..9ca3e87d3 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -14,6 +14,7 @@ import { } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; +import { TFindSecretsByFolderIdsFilter } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; export type TSecretV2BridgeDALFactory = ReturnType; @@ -339,14 +340,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { folderIds: string[], userId?: string, tx?: Knex, - filters?: { - limit?: number; - offset?: number; - orderBy?: SecretsOrderBy; - orderDirection?: OrderByDirection; - search?: string; - tagSlugs?: string[]; - } + filters?: TFindSecretsByFolderIdsFilter ) => { try { // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) @@ -356,14 +350,20 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } const query = (tx || db.replicaNode())(TableName.SecretV2) - .whereIn("folderId", folderIds) + .whereIn(`${TableName.SecretV2}.folderId`, folderIds) .where((bd) => { if (filters?.search) { - void bd.whereILike("key", `%${filters?.search}%`); + if (filters?.includeTagsInSearch) { + void bd + .whereILike(`${TableName.SecretV2}.key`, `%${filters?.search}%`) + .orWhereILike(`${TableName.SecretTag}.slug`, `%${filters?.search}%`); + } else { + void bd.whereILike(`${TableName.SecretV2}.key`, `%${filters?.search}%`); + } } }) .where((bd) => { - void bd.whereNull("userId").orWhere({ userId: userId || null }); + void bd.whereNull(`${TableName.SecretV2}.userId`).orWhere({ userId: userId || null }); }) .leftJoin( TableName.SecretV2JnTag, @@ -385,7 +385,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { .where((bd) => { const slugs = filters?.tagSlugs?.filter(Boolean); if (slugs && slugs.length > 0) { - void bd.whereIn("slug", slugs); + void bd.whereIn(`${TableName.SecretTag}.slug`, slugs); } }) .orderBy( diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 78d771cd5..75b7fcca9 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -43,6 +43,7 @@ import { TGetASecretDTO, TGetSecretReferencesTreeDTO, TGetSecretsDTO, + TGetSecretsRawByFolderMappingsDTO, TGetSecretVersionsDTO, TMoveSecretsDTO, TSecretReference, @@ -652,6 +653,56 @@ export const secretV2BridgeServiceFactory = ({ return count; }; + const getSecretsByFolderMappings = async ( + { projectId, userId, filters, folderMappings }: TGetSecretsRawByFolderMappingsDTO, + projectPermission: Awaited>["permission"] + ) => { + const groupedFolderMappings = groupBy(folderMappings, (folderMapping) => folderMapping.folderId); + + const secrets = await secretDAL.findByFolderIds( + folderMappings.map((folderMapping) => folderMapping.folderId), + userId, + undefined, + filters + ); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedSecrets = secrets + .filter((el) => + projectPermission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: groupedFolderMappings[el.folderId][0].environment, + secretPath: groupedFolderMappings[el.folderId][0].path, + secretName: el.key, + secretTags: el.tags.map((i) => i.slug) + }) + ) + ) + .map((secret) => + reshapeBridgeSecret( + projectId, + groupedFolderMappings[secret.folderId][0].environment, + groupedFolderMappings[secret.folderId][0].path, + { + ...secret, + value: secret.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() + : "", + comment: secret.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() + : "" + } + ) + ); + + return decryptedSecrets; + }; + // get secrets for multiple envs const getSecretsMultiEnv = async ({ actorId, @@ -678,59 +729,28 @@ export const secretV2BridgeServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); } - let paths: { folderId: string; path: string; environment: string }[] = []; - const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path); if (!folders.length) { return []; } - paths = folders.map((folder) => ({ folderId: folder.id, path, environment: folder.environment.slug })); + const folderMappings = folders.map((folder) => ({ + folderId: folder.id, + path, + environment: folder.environment.slug + })); - const groupedPaths = groupBy(paths, (p) => p.folderId); - - const secrets = await secretDAL.findByFolderIds( - paths.map((p) => p.folderId), - actorId, - undefined, - params + const decryptedSecrets = await getSecretsByFolderMappings( + { + projectId, + folderMappings, + filters: params, + userId: actorId + }, + permission ); - const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.SecretManager, - projectId - }); - - const decryptedSecrets = secrets - .filter((el) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: groupedPaths[el.folderId][0].environment, - secretPath: groupedPaths[el.folderId][0].path, - secretName: el.key, - secretTags: el.tags.map((i) => i.slug) - }) - ) - ) - .map((secret) => - reshapeBridgeSecret( - projectId, - groupedPaths[secret.folderId][0].environment, - groupedPaths[secret.folderId][0].path, - { - ...secret, - value: secret.encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() - : "", - comment: secret.encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() - : "" - } - ) - ); - return decryptedSecrets; }; @@ -2027,6 +2047,7 @@ export const secretV2BridgeServiceFactory = ({ getSecretsCount, getSecretsCountMultiEnv, getSecretsMultiEnv, - getSecretReferenceTree + getSecretReferenceTree, + getSecretsByFolderMappings }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 8a3e08e35..e621f8edb 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -285,3 +285,20 @@ export type TGetSecretReferencesTreeDTO = { environment: string; secretPath: string; } & Omit; + +export type TFindSecretsByFolderIdsFilter = { + limit?: number; + offset?: number; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + search?: string; + tagSlugs?: string[]; + includeTagsInSearch?: boolean; +}; + +export type TGetSecretsRawByFolderMappingsDTO = { + projectId: string; + folderMappings: { folderId: string; path: string; environment: string }[]; + userId: string; + filters: TFindSecretsByFolderIdsFilter; +}; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 13eca368f..67fc8c6f8 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -27,6 +27,8 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { groupBy, pick } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { ProjectServiceActor } from "@app/lib/types"; +import { TGetSecretsRawByFolderMappingsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; import { ActorType } from "../auth/auth-type"; import { TProjectDALFactory } from "../project/project-dal"; @@ -2845,6 +2847,27 @@ export const secretServiceFactory = ({ return { message: "Migrating project to new KMS architecture" }; }; + const getSecretsRawByFolderMappings = async ( + params: Omit, + actor: ProjectServiceActor + ) => { + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(params.projectId); + + if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" }); + + const { permission } = await permissionService.getProjectPermission( + actor.type, + actor.id, + params.projectId, + actor.authMethod, + actor.orgId + ); + + const secrets = secretV2BridgeService.getSecretsByFolderMappings({ ...params, userId: actor.id }, permission); + + return secrets; + }; + return { attachTags, detachTags, @@ -2871,6 +2894,7 @@ export const secretServiceFactory = ({ getSecretsCount, getSecretsCountMultiEnv, getSecretsRawMultiEnv, - getSecretReferenceTree + getSecretReferenceTree, + getSecretsRawByFolderMappings }; }; diff --git a/frontend/src/components/v2/Checkbox/Checkbox.tsx b/frontend/src/components/v2/Checkbox/Checkbox.tsx index 751a026c3..6a1086996 100644 --- a/frontend/src/components/v2/Checkbox/Checkbox.tsx +++ b/frontend/src/components/v2/Checkbox/Checkbox.tsx @@ -16,6 +16,7 @@ export type CheckboxProps = Omit< checkIndicatorBg?: string | undefined; isError?: boolean; isIndeterminate?: boolean; + containerClassName?: string; }; export const Checkbox = ({ @@ -28,10 +29,11 @@ export const Checkbox = ({ checkIndicatorBg, isError, isIndeterminate, + containerClassName, ...props }: CheckboxProps): JSX.Element => { return ( -
+
{ + if (text.length < maxLength) return text; + + return `...${text.substring(text.length - maxLength + 3)}`; +}; diff --git a/frontend/src/hooks/api/dashboard/index.ts b/frontend/src/hooks/api/dashboard/index.ts index b0fb9324a..83206bdf8 100644 --- a/frontend/src/hooks/api/dashboard/index.ts +++ b/frontend/src/hooks/api/dashboard/index.ts @@ -1 +1,5 @@ -export { useGetProjectSecretsDetails } from "./queries"; +export { + useGetProjectSecretsDetails, + useGetProjectSecretsOverview, + useGetProjectSecretsQuickSearch +} from "./queries"; diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index 3f959f905..7654ec07f 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -10,12 +10,15 @@ import { DashboardProjectSecretsOverview, DashboardProjectSecretsOverviewResponse, DashboardSecretsOrderBy, + TDashboardProjectSecretsQuickSearch, + TDashboardProjectSecretsQuickSearchResponse, TGetDashboardProjectSecretsDetailsDTO, - TGetDashboardProjectSecretsOverviewDTO + TGetDashboardProjectSecretsOverviewDTO, + TGetDashboardProjectSecretsQuickSearchDTO } from "@app/hooks/api/dashboard/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries"; -import { unique } from "@app/lib/fn/array"; +import { groupBy, unique } from "@app/lib/fn/array"; export const dashboardKeys = { all: () => ["dashboard"] as const, @@ -42,8 +45,18 @@ export const dashboardKeys = { }: TGetDashboardProjectSecretsDetailsDTO) => [ ...dashboardKeys.getDashboardSecrets({ projectId, secretPath }), - environment, "secrets-details", + environment, + params + ] as const, + getProjectSecretsQuickSearch: ({ + projectId, + secretPath, + ...params + }: TGetDashboardProjectSecretsQuickSearchDTO) => + [ + ...dashboardKeys.getDashboardSecrets({ projectId, secretPath }), + "quick-search", params ] as const }; @@ -256,3 +269,101 @@ export const useGetProjectSecretsDetails = ( keepPreviousData: true }); }; + +export const fetchProjectSecretsQuickSearch = async ({ + environments, + tags, + ...params +}: TGetDashboardProjectSecretsQuickSearchDTO) => { + const { data } = await apiRequest.get( + "/api/v1/dashboard/secrets-deep-search", + { + params: { + ...params, + environments: encodeURIComponent(environments.join(",")), + tags: encodeURIComponent( + Object.entries(tags) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([_, enabled]) => enabled) + .map(([tag]) => tag) + .join(",") + ) + } + } + ); + + return data; +}; + +export const useGetProjectSecretsQuickSearch = ( + { + projectId, + secretPath, + search = "", + environments, + tags + }: TGetDashboardProjectSecretsQuickSearchDTO, + options?: Omit< + UseQueryOptions< + TDashboardProjectSecretsQuickSearchResponse, + unknown, + TDashboardProjectSecretsQuickSearch, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + ...options, + enabled: + Boolean(search?.trim() || Object.values(tags).length) && + (options?.enabled ?? true) && + Boolean(environments.length), + queryKey: dashboardKeys.getProjectSecretsQuickSearch({ + secretPath, + search, + projectId, + environments, + tags + }), + queryFn: () => + fetchProjectSecretsQuickSearch({ + secretPath, + search, + projectId, + environments, + tags + }), + onError: (error) => { + if (axios.isAxiosError(error)) { + const serverResponse = error.response?.data as { message: string }; + createNotification({ + title: "Error fetching secrets deep search", + type: "error", + text: serverResponse.message + }); + } + }, + select: useCallback((data: Awaited>) => { + const { secrets, folders, dynamicSecrets } = data; + + const groupedFolders = groupBy(folders, (folder) => folder.path); + const groupedSecrets = groupBy( + mergePersonalSecrets(secrets), + (secret) => `${secret.path === "/" ? "" : secret.path}/${secret.key}` + ); + const groupedDynamicSecrets = groupBy( + dynamicSecrets, + (dynamicSecret) => + `${dynamicSecret.path === "/" ? "" : dynamicSecret.path}/${dynamicSecret.name}` + ); + + return { + folders: groupedFolders, + secrets: groupedSecrets, + dynamicSecrets: groupedDynamicSecrets + }; + }, []), + keepPreviousData: true + }); +}; diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 865d8541d..da121a349 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -69,3 +69,23 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit< includeImports?: boolean; tags: Record; }; + +export type TDashboardProjectSecretsQuickSearchResponse = { + folders: (TSecretFolder & { environment: string; path: string })[]; + dynamicSecrets: (TDynamicSecret & { environment: string; path: string })[]; + secrets: SecretV3Raw[]; +}; + +export type TDashboardProjectSecretsQuickSearch = { + folders: Record; + secrets: Record; + dynamicSecrets: Record; +}; + +export type TGetDashboardProjectSecretsQuickSearchDTO = { + projectId: string; + secretPath: string; + tags: Record; + search: string; + environments: string[]; +}; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index dff4d18a0..b3b3a4164 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -66,7 +66,8 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => { createdAt: el.createdAt, updatedAt: el.updatedAt, version: el.version, - skipMultilineEncoding: el.skipMultilineEncoding + skipMultilineEncoding: el.skipMultilineEncoding, + path: el.secretPath }; if (el.type === SecretType.Personal) { diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 956685178..7e26914ab 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -29,7 +29,7 @@ export type EncryptedSecret = { tags: WsTag[]; }; -// both personal and shared secret stitiched together for dashboard +// both personal and shared secret stitched together for dashboard export type SecretV3RawSanitized = { id: string; version: number; @@ -42,6 +42,7 @@ export type SecretV3RawSanitized = { createdAt: string; updatedAt: string; env: string; + path?: string; valueOverride?: string; idOverride?: string; overrideAction?: string; @@ -57,6 +58,7 @@ export type SecretV3Raw = { version: number; type: string; secretKey: string; + secretPath: string; secretValue?: string; secretComment?: string; secretReminderNote?: string; diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.tsx b/frontend/src/views/SecretMainPage/SecretMainPage.tsx index 5f66e785b..4e21a8d86 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.tsx +++ b/frontend/src/views/SecretMainPage/SecretMainPage.tsx @@ -186,7 +186,9 @@ const SecretMainPageContent = () => { }); // fetch tags - const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : ""); + const { data: tags } = useGetWsTags( + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : "" + ); const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ workspaceId, @@ -305,6 +307,32 @@ const SecretMainPageContent = () => { } }, [secretPath]); + useEffect(() => { + if (!router.query.search && !router.query.tags) return; + + const queryTags = router.query.tags + ? (router.query.tags as string).split(",").filter((tag) => Boolean(tag.trim())) + : []; + const updatedTags: Record = {}; + queryTags.forEach((tag) => { + updatedTags[tag] = true; + }); + + setFilter((prev) => ({ + ...prev, + ...defaultFilterState, + searchFilter: (router.query.search as string) ?? "", + tags: updatedTags + })); + setDebouncedSearchFilter(router.query.search as string); + // this is a temp workaround until we fully transition state to query params, + const { search, tags: qTags, ...query } = router.query; + router.push({ + pathname: router.pathname, + query + }); + }, [router.query.search, router.query.tags]); + const selectedSecrets = useSelectedSecrets(); const selectedSecretActions = useSelectedSecretActions(); @@ -389,8 +417,29 @@ const SecretMainPageContent = () => { "sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium" )} > + 0 + ? `${ + !allRowsSelectedOnPage.isChecked ? "Select" : "Unselect" + } all secrets on page` + : "" + } + > +
+ e.stopPropagation()} + isChecked={allRowsSelectedOnPage.isChecked} + isIndeterminate={allRowsSelectedOnPage.isIndeterminate} + onCheckedChange={toggleSelectAllRows} + /> +
+
{ if (evt.key === "Enter") handleSortToggle(); }} > - 0 - ? `${ - !allRowsSelectedOnPage.isChecked ? "Select" : "Unselect" - } all secrets on page` - : "" - } - > -
- e.stopPropagation()} - isChecked={allRowsSelectedOnPage.isChecked} - isIndeterminate={allRowsSelectedOnPage.isIndeterminate} - onCheckedChange={toggleSelectAllRows} - /> -
-
Key {
Value
- )} - {canReadSecretImports && Boolean(imports?.length) && ( - - )} - {Boolean(folders?.length) && ( - - )} - {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( - - )} - {canReadSecret && Boolean(secrets?.length) && ( - - )} - {canReadSecret && } - {!canReadSecret && - !canReadDynamicSecret && - !canReadSecretImports && - folders?.length === 0 && } -
+ )} + {canReadSecretImports && Boolean(imports?.length) && ( + + )} + {Boolean(folders?.length) && ( + + )} + {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( + + )} + {canReadSecret && Boolean(secrets?.length) && ( + + )} + {canReadSecret && } + {!canReadSecret && + !canReadDynamicSecret && + !canReadSecretImports && + folders?.length === 0 && } + {!isDetailsLoading && totalCount > 0 && ( { try { await createFolder({ @@ -269,22 +271,15 @@ export const ActionBar = ({ return ( <>
-
- - } - value={filter.searchFilter} - onChange={(evt) => { - onSearchChange(evt.target.value); - }} - /> -
+ env.slug === environment)!]} + projectId={workspaceId} + tags={tags} + />
@@ -364,8 +359,10 @@ export const ActionBar = ({ > Tags - - Apply tags to filter secrets + + + Apply Tags to Filter Secrets + {tags.map(({ id, slug, color }) => ( { @@ -466,7 +463,10 @@ export const ActionBar = ({
{(isAllowed) => (