diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts index cf236b56f5..1718e09fd0 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts @@ -214,7 +214,10 @@ export const secretRotationV2DALFactory = ( tx?: Knex ) => { try { - const extendedQuery = baseSecretRotationV2Query({ filter, db, tx, options }) + const { limit, offset = 0, sort, ...queryOptions } = options || {}; + const baseOptions = { ...queryOptions }; + + const subquery = baseSecretRotationV2Query({ filter, db, tx, options: baseOptions }) .join( TableName.SecretRotationV2SecretMapping, `${TableName.SecretRotationV2SecretMapping}.rotationId`, @@ -233,6 +236,7 @@ export const secretRotationV2DALFactory = ( ) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) .select( + selectAllTableCols(TableName.SecretRotationV2), db.ref("id").withSchema(TableName.SecretV2).as("secretId"), db.ref("key").withSchema(TableName.SecretV2).as("secretKey"), db.ref("version").withSchema(TableName.SecretV2).as("secretVersion"), @@ -252,18 +256,31 @@ export const secretRotationV2DALFactory = ( db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), - db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue"), + db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.SecretRotationV2}."createdAt" DESC) as rank`) ); if (search) { - void extendedQuery.where((query) => { - void query + void subquery.where((qb) => { + void qb .whereILike(`${TableName.SecretV2}.key`, `%${search}%`) .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`); }); } - const secretRotations = await extendedQuery; + let secretRotations: Awaited; + if (limit !== undefined) { + const rankOffset = offset + 1; + const queryWithLimit = (tx || db) + .with("inner", subquery) + .select("*") + .from("inner") + .where("inner.rank", ">=", rankOffset) + .andWhere("inner.rank", "<", rankOffset + limit); + secretRotations = (await queryWithLimit) as unknown as Awaited; + } else { + secretRotations = await subquery; + } if (!secretRotations.length) return []; diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 8cf9604a43..7dc763730f 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -624,7 +624,10 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), - tags: SanitizedTagSchema.array().optional() + tags: SanitizedTagSchema.array().optional(), + reminder: RemindersSchema.extend({ + recipients: z.string().array() + }).nullable() }) .nullable() .array() @@ -743,6 +746,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ReturnType >[number]["secrets"][number] & { isEmpty: boolean; + reminder: Awaited>[string] | null; } > | null)[]; })[] @@ -847,27 +851,38 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ); if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) { - secretRotations = ( - await server.services.secretRotationV2.getDashboardSecretRotations( - { - projectId, - search, - orderBy, - orderDirection, - environments: [environment], - secretPath, - limit: remainingLimit, - offset: adjustedOffset - }, - req.permission - ) - ).map((rotation) => ({ + const rawSecretRotations = await server.services.secretRotationV2.getDashboardSecretRotations( + { + projectId, + search, + orderBy, + orderDirection, + environments: [environment], + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ); + + const allRotationSecretIds = rawSecretRotations + .flatMap((rotation) => rotation.secrets) + .filter((secret) => Boolean(secret)) + .map((secret) => secret.id); + + const rotationReminders = + allRotationSecretIds.length > 0 + ? await server.services.reminder.getRemindersForDashboard(allRotationSecretIds) + : {}; + + secretRotations = rawSecretRotations.map((rotation) => ({ ...rotation, secrets: rotation.secrets.map((secret) => secret ? { ...secret, - isEmpty: !secret.secretValue + isEmpty: !secret.secretValue, + reminder: rotationReminders[secret.id] ?? null } : secret ) @@ -948,7 +963,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { search, tagSlugs: tags, includeTagsInSearch: true, - includeMetadataInSearch: true + includeMetadataInSearch: true, + excludeRotatedSecrets: includeSecretRotations }); if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { @@ -970,7 +986,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { offset: adjustedOffset, tagSlugs: tags, includeTagsInSearch: true, - includeMetadataInSearch: true + includeMetadataInSearch: true, + excludeRotatedSecrets: includeSecretRotations }) ).secrets; 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 d8220e4f54..0753f9640c 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 @@ -416,6 +416,7 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { tagSlugs?: string[]; includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; + excludeRotatedSecrets?: boolean; } ) => { try { @@ -481,6 +482,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { ); } + if (filters?.excludeRotatedSecrets) { + void query.whereNull(`${TableName.SecretRotationV2SecretMapping}.secretId`); + } + const secrets = await query; // @ts-expect-error not inferred by knex @@ -594,6 +599,11 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { void bd.whereIn(`${TableName.SecretTag}.slug`, slugs); } }) + .where((bd) => { + if (filters?.excludeRotatedSecrets) { + void bd.whereNull(`${TableName.SecretRotationV2SecretMapping}.secretId`); + } + }) .orderBy( filters?.orderBy === SecretsOrderBy.Name ? "key" : "id", filters?.orderDirection ?? OrderByDirection.ASC 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 d42a26effc..d8d06bd462 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 @@ -483,8 +483,8 @@ export const secretV2BridgeServiceFactory = ({ }); if (!sharedSecretToModify) throw new NotFoundError({ message: `Secret with name ${inputSecret.secretName} not found` }); - if (sharedSecretToModify.isRotatedSecret && (inputSecret.newSecretName || inputSecret.secretValue)) - throw new BadRequestError({ message: "Cannot update rotated secret name or value" }); + if (sharedSecretToModify.isRotatedSecret && inputSecret.newSecretName) + throw new BadRequestError({ message: "Cannot update rotated secret name" }); secretId = sharedSecretToModify.id; secret = sharedSecretToModify; } @@ -888,6 +888,7 @@ export const secretV2BridgeServiceFactory = ({ | "tagSlugs" | "environment" | "search" + | "excludeRotatedSecrets" >) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -1934,8 +1935,14 @@ export const secretV2BridgeServiceFactory = ({ if (el.isRotatedSecret) { const input = secretsToUpdateGroupByPath[secretPath].find((i) => i.secretKey === el.key); - if (input && (input.newSecretName || input.secretValue)) - throw new BadRequestError({ message: `Cannot update rotated secret name or value: ${el.key}` }); + if (input) { + if (input.newSecretName) { + delete input.newSecretName; + } + if (input.secretValue !== undefined) { + delete input.secretValue; + } + } } }); @@ -2061,8 +2068,11 @@ export const secretV2BridgeServiceFactory = ({ commitChanges, inputSecrets: secretsToUpdate.map((el) => { const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0]; + const shouldUpdateValue = !originalSecret.isRotatedSecret && typeof el.secretValue !== "undefined"; + const shouldUpdateName = !originalSecret.isRotatedSecret && el.newSecretName; + const encryptedValue = - typeof el.secretValue !== "undefined" + shouldUpdateValue && el.secretValue !== undefined ? { encryptedValue: secretManagerEncryptor({ plainText: Buffer.from(el.secretValue) }).cipherTextBlob, references: secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences @@ -2077,7 +2087,7 @@ export const secretV2BridgeServiceFactory = ({ (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob ), skipMultilineEncoding: el.skipMultilineEncoding, - key: el.newSecretName || el.secretKey, + key: shouldUpdateName ? el.newSecretName : el.secretKey, tags: el.tagIds, secretMetadata: el.secretMetadata, ...encryptedValue 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 5e2ffc1a0f..f8613f57a3 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 @@ -50,6 +50,7 @@ export type TGetSecretsDTO = { limit?: number; search?: string; keys?: string[]; + excludeRotatedSecrets?: boolean; } & TProjectPermission; export type TGetSecretsMissingReadValuePermissionDTO = Omit< @@ -362,6 +363,7 @@ export type TFindSecretsByFolderIdsFilter = { includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; keys?: string[]; + excludeRotatedSecrets?: boolean; }; export type TGetSecretsRawByFolderMappingsDTO = { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 9ba1df47d7..27690249db 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1154,6 +1154,7 @@ export const secretServiceFactory = ({ | "search" | "includeTagsInSearch" | "includeMetadataInSearch" + | "excludeRotatedSecrets" >) => { const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index d8c778d7e3..be2c8b2149 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -214,6 +214,7 @@ export type TGetSecretsRawDTO = { keys?: string[]; includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; + excludeRotatedSecrets?: boolean; } & TProjectPermission; export type TGetSecretAccessListDTO = { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 77ea94f993..aad3eb56a3 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -685,12 +685,17 @@ const Page = () => { setDebouncedSearchFilter(""); }; - const getMergedSecretsWithPending = () => { + const getMergedSecretsWithPending = ( + paramSecrets?: (SecretV3RawSanitized | null)[] + ): SecretV3RawSanitized[] => { + const sanitizedParamSecrets = paramSecrets?.filter(Boolean) as + | SecretV3RawSanitized[] + | undefined; if (!isBatchMode || pendingChanges.secrets.length === 0) { - return secrets; + return sanitizedParamSecrets || secrets || []; } - const mergedSecrets = [...(secrets || [])] as (SecretV3RawSanitized & { + const mergedSecrets = [...(sanitizedParamSecrets || secrets || [])] as (SecretV3RawSanitized & { originalKey?: string; })[]; @@ -1072,7 +1077,17 @@ const Page = () => { /> )} {canReadSecretRotations && Boolean(secretRotations?.length) && ( - + )} {canReadSecret && Boolean(mergedSecrets?.length) && ( ( - + { const queryClient = useQueryClient(); const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ @@ -580,27 +582,32 @@ export const SecretListView = ({ {FontAwesomeSpriteSymbols.map(({ icon, symbol }) => ( ))} - {secrets.map((secret) => ( - - ))} + {secrets + .filter((secret) => { + if (!excludePendingCreates) return true; + return !secret.isPending || secret.pendingAction !== PendingAction.Create; + }) + .map((secret) => ( + + ))} void; onViewGeneratedCredentials: () => void; onDelete: () => void; + projectId: string; + secretPath?: string; + tags?: WsTag[]; + isProtectedBranch?: boolean; + usedBySecretSyncs?: UsedBySecretSyncs[]; + importedBy?: { + environment: { name: string; slug: string }; + folders: { + name: string; + secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[]; + isImported: boolean; + }[]; + }[]; + colWidth: number; + getMergedSecretsWithPending: ( + paramSecrets?: (SecretV3RawSanitized | null)[] + ) => SecretV3RawSanitized[]; }; export const SecretRotationItem = ({ @@ -35,16 +55,40 @@ export const SecretRotationItem = ({ onEdit, onRotate, onViewGeneratedCredentials, - onDelete + onDelete, + projectId, + secretPath = "/", + tags = [], + isProtectedBranch = false, + usedBySecretSyncs, + importedBy, + colWidth, + getMergedSecretsWithPending }: Props) => { const { name, type, environment, folder, secrets, description } = secretRotation; const { name: rotationType, image } = SECRET_ROTATION_MAP[type]; const [showSecrets, setShowSecrets] = useState(false); + const [isExpanded, setIsExpanded] = useState(true); return ( <> -
+
setIsExpanded(!isExpanded)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setIsExpanded(!isExpanded); + } + }} + role="button" + tabIndex={0} + aria-expanded={isExpanded} + aria-label={`${isExpanded ? "Collapse" : "Expand"} rotation secrets for ${name}`} + >
@@ -198,6 +242,20 @@ export const SecretRotationItem = ({
+ {isExpanded && ( + + )} e.preventDefault()} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationListView.tsx index 1682a79df8..1de6aa2015 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationListView.tsx @@ -3,15 +3,44 @@ import { EditSecretRotationV2Modal } from "@app/components/secret-rotations-v2/E import { RotateSecretRotationV2Modal } from "@app/components/secret-rotations-v2/RotateSecretRotationV2Modal"; import { ViewSecretRotationV2GeneratedCredentialsModal } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials"; import { usePopUp } from "@app/hooks"; +import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types"; import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; +import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types"; import { SecretRotationItem } from "./SecretRotationItem"; type Props = { secretRotations?: TSecretRotationV2[]; + projectId: string; + secretPath?: string; + tags?: WsTag[]; + isProtectedBranch?: boolean; + usedBySecretSyncs?: UsedBySecretSyncs[]; + importedBy?: { + environment: { name: string; slug: string }; + folders: { + name: string; + secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[]; + isImported: boolean; + }[]; + }[]; + colWidth: number; + getMergedSecretsWithPending: ( + secretParams?: (SecretV3RawSanitized | null)[] + ) => SecretV3RawSanitized[]; }; -export const SecretRotationListView = ({ secretRotations }: Props) => { +export const SecretRotationListView = ({ + secretRotations, + projectId, + secretPath = "/", + tags = [], + isProtectedBranch = false, + usedBySecretSyncs, + importedBy, + colWidth, + getMergedSecretsWithPending +}: Props) => { const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ "editSecretRotation", "rotateSecretRotation", @@ -31,6 +60,14 @@ export const SecretRotationListView = ({ secretRotations }: Props) => { handlePopUpOpen("viewSecretRotationGeneratedCredentials", secretRotation) } onDelete={() => handlePopUpOpen("deleteSecretRotation", secretRotation)} + colWidth={colWidth} + tags={tags} + projectId={projectId} + secretPath={secretPath} + isProtectedBranch={isProtectedBranch} + importedBy={importedBy} + usedBySecretSyncs={usedBySecretSyncs} + getMergedSecretsWithPending={getMergedSecretsWithPending} /> ))}