From 72ab61ecfa958efcb564d00e0f2316b29a1cae61 Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Fri, 17 Apr 2026 16:08:21 -0300 Subject: [PATCH 01/10] feat: add bulk export functionality for KMS private keys - Introduced a new endpoint for bulk exporting private keys. - Added corresponding types and interfaces for handling bulk export requests and responses. - Updated the audit log to track bulk export events. - Enhanced the KMS service and data access layer to support bulk key retrieval. - Implemented frontend hooks and components for initiating bulk exports and handling responses. --- .../ee/services/audit-log/audit-log-types.ts | 10 + .../src/ee/services/license/license-types.ts | 58 +- backend/src/lib/api-docs/constants.ts | 4 + backend/src/server/routes/v1/cmek-router.ts | 55 ++ backend/src/services/cmek/cmek-service.ts | 68 +- backend/src/services/cmek/cmek-types.ts | 4 + backend/src/services/kms/kms-key-dal.ts | 75 +- backend/src/services/kms/kms-service.ts | 20 + backend/src/services/kms/kms-types.ts | 4 + frontend/src/hooks/api/cmeks/mutations.tsx | 15 + frontend/src/hooks/api/cmeks/types.ts | 17 + .../kms/OverviewPage/components/CmekTable.tsx | 873 +++++++++--------- .../kms/OverviewPage/components/jsonExport.ts | 49 + 13 files changed, 793 insertions(+), 459 deletions(-) create mode 100644 frontend/src/pages/kms/OverviewPage/components/jsonExport.ts diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 41d5206eb28..300c547d644 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -473,6 +473,7 @@ export enum EventType { CMEK_LIST_SIGNING_ALGORITHMS = "cmek-list-signing-algorithms", CMEK_GET_PUBLIC_KEY = "cmek-get-public-key", CMEK_GET_PRIVATE_KEY = "cmek-get-private-key", + CMEK_BULK_GET_PRIVATE_KEYS = "cmek-bulk-export-private-keys", UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping", GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping", @@ -3648,6 +3649,14 @@ interface CmekGetPrivateKeyEvent { }; } +interface CmekBulkGetPrivateKeysEvent { + type: EventType.CMEK_BULK_GET_PRIVATE_KEYS; + metadata: { + keyIds: string[]; + keyNames: string[]; + }; +} + interface GetExternalGroupOrgRoleMappingsEvent { type: EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS; metadata?: Record; // not needed, based off orgId @@ -6288,6 +6297,7 @@ export type Event = | CmekListSigningAlgorithmsEvent | CmekGetPublicKeyEvent | CmekGetPrivateKeyEvent + | CmekBulkGetPrivateKeysEvent | GetExternalGroupOrgRoleMappingsEvent | UpdateExternalGroupOrgRoleMappingsEvent | GetProjectTemplatesEvent diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index bd556a50658..c7446ba201a 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -35,43 +35,43 @@ export type TFeatureSet = { _id: null; slug: string | null; tier: -1; - workspaceLimit: null; + workspaceLimit: null | number; workspacesUsed: number; - dynamicSecret: false; + dynamicSecret: boolean; memberLimit: null; membersUsed: number; - identityLimit: null; + identityLimit: null | number; identitiesUsed: number; - subOrganization: false; - environmentLimit: null; + subOrganization: boolean; + environmentLimit: null | number; environmentsUsed: 0; secretVersioning: true; - pitRecovery: false; - ipAllowlisting: false; - rbac: false; - customRateLimits: false; - customAlerts: false; - auditLogs: false; - auditLogsRetentionDays: 0; - auditLogStreams: false; + pitRecovery: boolean; + ipAllowlisting: boolean; + rbac: boolean; + customRateLimits: boolean; + customAlerts: boolean; + auditLogs: boolean; + auditLogsRetentionDays: number; + auditLogStreams: boolean; auditLogStreamLimit: 3; - githubOrgSync: false; - samlSSO: false; - enforceGoogleSSO: false; - hsm: false; - oidcSSO: false; - secretAccessInsights: false; - scim: false; - ldap: false; - groups: false; + githubOrgSync: boolean; + samlSSO: boolean; + enforceGoogleSSO: boolean; + hsm: boolean; + oidcSSO: boolean; + secretAccessInsights: boolean; + scim: boolean; + ldap: boolean; + groups: boolean; status: null; trial_end: null; has_used_trial: true; - secretApproval: false; - secretRotation: false; + secretApproval: boolean; + secretRotation: boolean; caCrl: false; - instanceUserManagement: false; - externalKms: false; + instanceUserManagement: boolean; + externalKms: boolean; rateLimits: { readLimit: number; writeLimit: number; @@ -91,9 +91,9 @@ export type TFeatureSet = { enterpriseAppConnections: false; machineIdentityAuthTemplates: false; pkiLegacyTemplates: false; - fips: false; - eventSubscriptions: false; - secretShareExternalBranding: false; + fips: boolean; + eventSubscriptions: boolean; + secretShareExternalBranding: boolean; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 50a771fab0c..32f1fb992a1 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2471,6 +2471,10 @@ export const KMS = { keyId: "The ID of the key to export the private key or key material for." }, + BULK_EXPORT_PRIVATE_KEYS: { + keyIds: "An array of KMS key IDs to export. Maximum 100 keys per request." + }, + SIGN: { keyId: "The ID of the key to sign the data with.", data: "The data in string format to be signed (base64 encoded).", diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index bb45738625b..c59b51838f1 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -518,6 +518,61 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/keys/bulk-export-private-keys", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + operationId: "bulkExportKmsKeyPrivateKeys", + tags: [ApiDocsTags.KmsKeys], + description: + "Bulk export multiple KMS keys. For asymmetric keys (sign/verify), both private and public keys are returned. For symmetric keys (encrypt/decrypt), the key material is returned.", + body: z.object({ + keyIds: z.array(z.string().uuid().describe(KMS.BULK_EXPORT_PRIVATE_KEYS.keyIds)).min(1).max(100) + }), + response: { + 200: z.object({ + keys: z.array( + z.object({ + keyId: z.string(), + name: z.string(), + keyUsage: z.string(), + algorithm: z.string(), + privateKey: z.string(), + publicKey: z.string().optional() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + body: { keyIds }, + permission + } = req; + + const { keys, projectId } = await server.services.cmek.bulkGetPrivateKeys({ keyIds }, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_BULK_GET_PRIVATE_KEYS, + metadata: { + keyIds: keys.map((k) => k.keyId), + keyNames: keys.map((k) => k.name) + } + } + }); + + return { keys }; + } + }); + server.route({ method: "GET", url: "/keys/:keyId/signing-algorithms", diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index 2acc95ee6d9..e3c45127f3b 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -3,11 +3,12 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { SigningAlgorithm } from "@app/lib/crypto/sign"; +import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { + TCmekBulkGetPrivateKeysDTO, TCmekDecryptDTO, TCmekEncryptDTO, TCmekGetPrivateKeyDTO, @@ -318,6 +319,68 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC }; }; + const bulkGetPrivateKeys = async ({ keyIds }: TCmekBulkGetPrivateKeysDTO, actor: OrgServiceActor) => { + if (keyIds.length === 0) throw new BadRequestError({ message: "At least one key ID is required" }); + + const keys = await kmsDAL.findCmeksByIds(keyIds); + + if (keys.length === 0) throw new NotFoundError({ message: "No keys found for the provided IDs" }); + + const projectIds = new Set(); + for (const key of keys) { + if (!key.projectId || key.isReserved) + throw new BadRequestError({ message: `Key with ID "${key.id}" is not customer managed` }); + if (key.isDisabled) throw new BadRequestError({ message: `Key with ID "${key.id}" is disabled` }); + projectIds.add(key.projectId); + } + + if (projectIds.size > 1) throw new BadRequestError({ message: "All keys must belong to the same project" }); + + const projectId = keys[0].projectId!; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCmekActions.ExportPrivateKey, + ProjectPermissionSub.Cmek + ); + + const bulkMaterials = await kmsService.getBulkKeyMaterial({ kmsIds: keyIds }); + + const result = await Promise.all( + keys.map(async (key) => { + const materialEntry = bulkMaterials.find((m) => m.kmsId === key.id); + const isAsymmetric = Object.values(AsymmetricKeyAlgorithm).includes( + key.encryptionAlgorithm as AsymmetricKeyAlgorithm + ); + + let publicKey: string | undefined; + if (isAsymmetric) { + const pubKeyBuffer = await kmsService.getPublicKey({ kmsId: key.id }); + publicKey = pubKeyBuffer.toString("base64"); + } + + return { + keyId: key.id, + name: key.name, + keyUsage: key.keyUsage, + algorithm: key.encryptionAlgorithm, + privateKey: materialEntry!.keyMaterial.toString("base64"), + ...(publicKey ? { publicKey } : {}) + }; + }) + ); + + return { keys: result, projectId }; + }; + const cmekSign = async ({ keyId, data, signingAlgorithm, isDigest }: TCmekSignDTO, actor: OrgServiceActor) => { const key = await kmsDAL.findCmekById(keyId); @@ -432,6 +495,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC cmekVerify, listSigningAlgorithms, getPublicKey, - getPrivateKey + getPrivateKey, + bulkGetPrivateKeys }; }; diff --git a/backend/src/services/cmek/cmek-types.ts b/backend/src/services/cmek/cmek-types.ts index eafd39e39fa..d1b3267182c 100644 --- a/backend/src/services/cmek/cmek-types.ts +++ b/backend/src/services/cmek/cmek-types.ts @@ -57,6 +57,10 @@ export type TCmekGetPrivateKeyDTO = { keyId: string; }; +export type TCmekBulkGetPrivateKeysDTO = { + keyIds: string[]; +}; + export type TCmekSignDTO = { keyId: string; data: string; diff --git a/backend/src/services/kms/kms-key-dal.ts b/backend/src/services/kms/kms-key-dal.ts index 36ffa336612..c7554c9c4f4 100644 --- a/backend/src/services/kms/kms-key-dal.ts +++ b/backend/src/services/kms/kms-key-dal.ts @@ -93,6 +93,62 @@ export const kmskeyDALFactory = (db: TDbClient) => { } }; + const findByIdsWithAssociatedKms = async (ids: string[], tx?: Knex) => { + try { + const results = await (tx || db.replicaNode())(TableName.KmsKey) + .whereIn(`${TableName.KmsKey}.id`, ids) + .join(TableName.Organization, `${TableName.KmsKey}.orgId`, `${TableName.Organization}.id`) + .leftJoin(TableName.InternalKms, `${TableName.KmsKey}.id`, `${TableName.InternalKms}.kmsKeyId`) + .leftJoin(TableName.ExternalKms, `${TableName.KmsKey}.id`, `${TableName.ExternalKms}.kmsKeyId`) + .select(selectAllTableCols(TableName.KmsKey)) + .select( + db.ref("id").withSchema(TableName.InternalKms).as("internalKmsId"), + db.ref("encryptedKey").withSchema(TableName.InternalKms).as("internalKmsEncryptedKey"), + db.ref("encryptionAlgorithm").withSchema(TableName.InternalKms).as("internalKmsEncryptionAlgorithm"), + db.ref("version").withSchema(TableName.InternalKms).as("internalKmsVersion") + ) + .select( + db.ref("id").withSchema(TableName.ExternalKms).as("externalKmsId"), + db.ref("provider").withSchema(TableName.ExternalKms).as("externalKmsProvider"), + db.ref("encryptedProviderInputs").withSchema(TableName.ExternalKms).as("externalKmsEncryptedProviderInput"), + db.ref("status").withSchema(TableName.ExternalKms).as("externalKmsStatus"), + db.ref("statusDetails").withSchema(TableName.ExternalKms).as("externalKmsStatusDetails") + ) + .select( + db.ref("kmsDefaultKeyId").withSchema(TableName.Organization).as("orgKmsDefaultKeyId"), + db.ref("kmsEncryptedDataKey").withSchema(TableName.Organization).as("orgKmsEncryptedDataKey") + ); + + return results.map((result) => ({ + ...KmsKeysSchema.parse(result), + isExternal: Boolean(result?.externalKmsId), + orgKms: { + id: result?.orgKmsDefaultKeyId, + encryptedDataKey: result?.orgKmsEncryptedDataKey + }, + externalKms: result?.externalKmsId + ? { + id: result.externalKmsId, + provider: result.externalKmsProvider, + encryptedProviderInput: result.externalKmsEncryptedProviderInput, + status: result.externalKmsStatus, + statusDetails: result.externalKmsStatusDetails + } + : undefined, + internalKms: result?.internalKmsId + ? { + id: result.internalKmsId, + encryptedKey: result.internalKmsEncryptedKey, + encryptionAlgorithm: result.internalKmsEncryptionAlgorithm, + version: result.internalKmsVersion + } + : undefined + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find by ids with associated kms" }); + } + }; + const findProjectCmeks = async (projectId: string, tx?: Knex) => { try { const result = await (tx || db.replicaNode())(TableName.KmsKey) @@ -180,6 +236,14 @@ export const kmskeyDALFactory = (db: TDbClient) => { } }; + const findCmeksByIds = async (ids: string[], tx?: Knex) => { + try { + return await baseCmekQuery({ db, tx }).whereIn(`${TableName.KmsKey}.id`, ids); + } catch (error) { + throw new DatabaseError({ error, name: "Find cmeks by IDs" }); + } + }; + const findCmekByName = async (keyName: string, projectId: string, tx?: Knex) => { try { const key = await baseCmekQuery({ @@ -194,5 +258,14 @@ export const kmskeyDALFactory = (db: TDbClient) => { } }; - return { ...kmsOrm, findByIdWithAssociatedKms, listCmeksByProjectId, findCmekById, findCmekByName, findProjectCmeks }; + return { + ...kmsOrm, + findByIdWithAssociatedKms, + findByIdsWithAssociatedKms, + listCmeksByProjectId, + findCmekById, + findCmeksByIds, + findCmekByName, + findProjectCmeks + }; }; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index ba025d9a206..8ac0b099b99 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -43,6 +43,7 @@ import { TEncryptWithKmsDataKeyDTO, TEncryptWithKmsDTO, TGenerateKMSDTO, + TGetBulkKeyMaterialDTO, TGetKeyMaterialDTO, TGetPublicKeyDTO, TImportKeyMaterialDTO, @@ -379,6 +380,24 @@ export const kmsServiceFactory = ({ return kmsKey; }; + const getBulkKeyMaterial = async ({ kmsIds }: TGetBulkKeyMaterialDTO) => { + const kmsDocs = await kmsDAL.findByIdsWithAssociatedKms(kmsIds); + + return kmsDocs.map((kmsDoc) => { + if (kmsDoc.isReserved) { + throw new BadRequestError({ message: `Cannot get key material for reserved key [kmsId=${kmsDoc.id}]` }); + } + if (kmsDoc.externalKms) { + throw new BadRequestError({ message: `Cannot get key material for external key [kmsId=${kmsDoc.id}]` }); + } + + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const keyMaterial = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + + return { kmsId: kmsDoc.id, name: kmsDoc.name, keyMaterial }; + }); + }; + const importKeyMaterial = async ( { key, algorithm, name, isReserved, projectId, orgId, keyUsage, kmipMetadata }: TImportKeyMaterialDTO, tx?: Knex @@ -1085,6 +1104,7 @@ export const kmsServiceFactory = ({ getKmsById, createCipherPairWithDataKey, getKeyMaterial, + getBulkKeyMaterial, importKeyMaterial, signWithKmsKey, verifyWithKmsKey, diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index 11badd7a510..f2c05b6ad89 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -91,6 +91,10 @@ export type TGetKeyMaterialDTO = { kmsId: string; }; +export type TGetBulkKeyMaterialDTO = { + kmsIds: string[]; +}; + export type TImportKeyMaterialDTO = { key: Buffer; algorithm: SymmetricKeyAlgorithm; diff --git a/frontend/src/hooks/api/cmeks/mutations.tsx b/frontend/src/hooks/api/cmeks/mutations.tsx index a32409dffb2..f322ff9e11e 100644 --- a/frontend/src/hooks/api/cmeks/mutations.tsx +++ b/frontend/src/hooks/api/cmeks/mutations.tsx @@ -4,6 +4,8 @@ import { encodeBase64 } from "@app/components/utilities/cryptography/crypto"; import { apiRequest } from "@app/config/request"; import { cmekKeys } from "@app/hooks/api/cmeks/queries"; import { + TCmekBulkExportPrivateKeysDTO, + TCmekBulkExportPrivateKeysResponse, TCmekDecrypt, TCmekDecryptResponse, TCmekEncrypt, @@ -130,3 +132,16 @@ export const useCmekDecrypt = () => { } }); }; + +export const useBulkExportCmekPrivateKeys = () => { + return useMutation({ + mutationFn: async ({ keyIds }: TCmekBulkExportPrivateKeysDTO) => { + const { data } = await apiRequest.post( + "/api/v1/kms/keys/bulk-export-private-keys", + { keyIds } + ); + + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/cmeks/types.ts b/frontend/src/hooks/api/cmeks/types.ts index f88717bab06..5163cd9e25b 100644 --- a/frontend/src/hooks/api/cmeks/types.ts +++ b/frontend/src/hooks/api/cmeks/types.ts @@ -92,6 +92,23 @@ export type TCmekGetPrivateKeyResponse = { privateKey: string; }; +export type TCmekBulkExportPrivateKeysDTO = { + keyIds: string[]; +}; + +export type TCmekBulkExportedKey = { + keyId: string; + name: string; + keyUsage: string; + algorithm: string; + privateKey: string; + publicKey?: string; +}; + +export type TCmekBulkExportPrivateKeysResponse = { + keys: TCmekBulkExportedKey[]; +}; + export enum CmekOrderBy { Name = "name" } diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index 1b8eec8297e..c775fc7089d 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -1,7 +1,5 @@ +import { useState } from "react"; import { - faArrowDown, - faArrowUp, - faArrowUpRightFromSquare, faCancel, faCheck, faCheckCircle, @@ -11,40 +9,55 @@ import { faEllipsis, faFileSignature, faInfoCircle, - faKey, faLock, faLockOpen, - faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { motion } from "framer-motion"; +import { ChevronDownIcon, SearchIcon } from "lucide-react"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; +import { Spinner } from "@app/components/v2"; import { + Badge, Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - EmptyState, - IconButton, - Input, - Pagination, - Spinner, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, + Checkbox, + DocumentationLinkBadge, + InputGroup, + InputGroupAddon, + InputGroupInput, + Skeleton, + TBadgeProps, Tooltip, - Tr -} from "@app/components/v2"; -import { Badge, TBadgeProps } from "@app/components/v3"; + TooltipContent, + TooltipTrigger, + UnstableCard, + UnstableCardAction, + UnstableCardContent, + UnstableCardDescription, + UnstableCardHeader, + UnstableCardTitle, + UnstableDropdownMenu, + UnstableDropdownMenuContent, + UnstableDropdownMenuItem, + UnstableDropdownMenuTrigger, + UnstableEmpty, + UnstableEmptyDescription, + UnstableEmptyHeader, + UnstableEmptyTitle, + UnstableIconButton, + UnstablePagination, + UnstableTable, + UnstableTableBody, + UnstableTableCell, + UnstableTableHead, + UnstableTableHeader, + UnstableTableRow +} from "@app/components/v3"; import { ProjectPermissionActions, ProjectPermissionCmekActions, @@ -59,7 +72,11 @@ import { setUserTablePreference } from "@app/helpers/userTablePreferences"; import { usePagination, usePopUp, useResetPageHelper, useTimedReset } from "@app/hooks"; -import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks"; +import { + useBulkExportCmekPrivateKeys, + useGetCmeksByProjectId, + useUpdateCmek +} from "@app/hooks/api/cmeks"; import { AsymmetricKeyAlgorithm, CmekOrderBy, @@ -75,21 +92,15 @@ import { CmekModal } from "./CmekModal"; import { CmekSignModal } from "./CmekSignModal"; import { CmekVerifyModal } from "./CmekVerifyModal"; import { DeleteCmekModal } from "./DeleteCmekModal"; +import { cmekKeysToExportJSON, downloadJSON } from "./jsonExport"; const getStatusBadgeProps = ( isDisabled: boolean ): { variant: TBadgeProps["variant"]; label: string } => { if (isDisabled) { - return { - variant: "danger", - label: "Disabled" - }; + return { variant: "danger", label: "Disabled" }; } - - return { - variant: "success", - label: "Active" - }; + return { variant: "success", label: "Active" }; }; export const CmekTable = () => { @@ -130,11 +141,12 @@ export const CmekTable = () => { }); const { keys = [], totalCount = 0 } = data ?? {}; - useResetPageHelper({ - totalCount, - offset, - setPage - }); + useResetPageHelper({ totalCount, offset, setPage }); + + const [selectedKeyIds, setSelectedKeyIds] = useState([]); + + const isPageSelected = keys.length > 0 && keys.every((k) => selectedKeyIds.includes(k.id)); + const isPageIndeterminate = !isPageSelected && keys.some((k) => selectedKeyIds.includes(k.id)); const [, isCopyingCiphertext, setCopyCipherText] = useTimedReset({ initialState: "", @@ -158,55 +170,62 @@ export const CmekTable = () => { }; const updateCmek = useUpdateCmek(); + const bulkExportMutation = useBulkExportCmekPrivateKeys(); const handleDisableCmek = async ({ id: keyId, isDisabled }: TCmek) => { - await updateCmek.mutateAsync({ - keyId, - projectId, - isDisabled: !isDisabled - }); - + await updateCmek.mutateAsync({ keyId, projectId, isDisabled: !isDisabled }); createNotification({ text: `Key successfully ${isDisabled ? "enabled" : "disabled"}`, type: "success" }); }; + const handleBulkExport = async () => { + const { keys: exportedKeys } = await bulkExportMutation.mutateAsync({ + keyIds: selectedKeyIds + }); + + try { + const exportData = cmekKeysToExportJSON(exportedKeys); + downloadJSON(exportData, `kms-keys-export-${new Date().toISOString().slice(0, 10)}.json`); + setSelectedKeyIds([]); + createNotification({ + text: `Successfully exported ${exportedKeys.length} key(s)`, + type: "success" + }); + } catch { + createNotification({ text: "Failed to export keys", type: "error" }); + } + }; + const cannotEditKey = permission.cannot( ProjectPermissionCmekActions.Edit, ProjectPermissionSub.Cmek ); - const cannotDeleteKey = permission.cannot( ProjectPermissionCmekActions.Delete, ProjectPermissionSub.Cmek ); - const cannotEncryptData = permission.cannot( ProjectPermissionCmekActions.Encrypt, ProjectPermissionSub.Cmek ); - const cannotDecryptData = permission.cannot( ProjectPermissionCmekActions.Decrypt, ProjectPermissionSub.Cmek ); - const cannotSignData = permission.cannot( ProjectPermissionCmekActions.Sign, ProjectPermissionSub.Cmek ); - const cannotVerifyData = permission.cannot( ProjectPermissionCmekActions.Verify, ProjectPermissionSub.Cmek ); - const cannotExportPrivateKey = permission.cannot( ProjectPermissionCmekActions.ExportPrivateKey, ProjectPermissionSub.Cmek ); - const cannotReadKey = permission.cannot( ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek @@ -220,397 +239,397 @@ export const CmekTable = () => { animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > -
-
-

Keys

- - +
0 && "h-16" + )} + > +
+
{selectedKeyIds.length} Selected
+ + {(isAllowed) => ( - + + + + + Export all selected keys as a JSON file + )}
- setSearch(e.target.value)} - leftIcon={} - placeholder="Search keys by name..." - /> - - - - - - - - - - - - - - - {isPending && } - {!isPending && - keys.length > 0 && - keys.map((cmek) => { - const { - name, - id, - version, - description, - encryptionAlgorithm, - isDisabled, - keyUsage - } = cmek; - const { variant, label } = getStatusBadgeProps(isDisabled); + - return ( - { - setCopyCipherText(""); - }} - > - - - - - - - - - ); - })} - -
-
- Name - - - -
-
Key IDKey UsageAlgorithmStatusVersion{isFetching ? : null}
-
- {name} - {description && ( - - - - )} -
-
-
- {id} - { - navigator.clipboard.writeText(id); - setCopyCipherText("Copied"); - }} - > - - -
-
-
- {kmsKeyUsageOptions[keyUsage].label} - - - -
-
{encryptionAlgorithm} - {label} - {version} -
- - - - - - - - {keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && ( - <> - -
- handlePopUpOpen("encryptData", cmek)} - icon={} - iconPos="left" - isDisabled={cannotEncryptData || isDisabled} - > - Encrypt Data - -
-
- -
- handlePopUpOpen("decryptData", cmek)} - icon={} - iconPos="left" - isDisabled={cannotDecryptData || isDisabled} - > - Decrypt Data - -
-
- - )} + + + + Keys + + + + Manage keys and perform cryptographic operations. + + + + {(isAllowed) => ( + + )} + + + + +
+ + + + + { + setSearch(e.target.value); + setSelectedKeyIds([]); + }} + placeholder="Search keys by name..." + /> + + {isFetching && } +
- {keyUsage === KmsKeyUsage.SIGN_VERIFY && ( - <> - -
- handlePopUpOpen("signData", cmek)} - icon={} - iconPos="left" - isDisabled={cannotSignData || isDisabled} - > - Sign Data - -
-
- -
- handlePopUpOpen("verifyData", cmek)} - icon={} - iconPos="left" - isDisabled={cannotVerifyData || isDisabled} - > - Verify Data - -
-
- - )} + {!isPending && keys.length === 0 ? ( + + + + {debouncedSearch.trim().length > 0 + ? "No keys match search filter" + : "No keys have been added to this project"} + + + {debouncedSearch.trim().length > 0 + ? "Try a different search term." + : "Add a key to get started."} + + + + ) : ( + + + + + { + if (isPageSelected) { + setSelectedKeyIds((prev) => + prev.filter((id) => !keys.find((k) => k.id === id)) + ); + } else { + setSelectedKeyIds((prev) => [ + ...new Set([...prev, ...keys.map((k) => k.id)]) + ]); + } + }} + /> + + + Name + + + Key ID + Key Usage + Algorithm + Status + Version + + + + + {isPending && + Array.from({ length: 5 }).map((_, i) => ( + // eslint-disable-next-line react/no-array-index-key + + {Array.from({ length: 8 }).map((__, j) => ( + // eslint-disable-next-line react/no-array-index-key + + + + ))} + + ))} + {!isPending && + keys.map((cmek) => { + const { + name, + id, + version, + description, + encryptionAlgorithm, + isDisabled, + keyUsage + } = cmek; + const { variant, label } = getStatusBadgeProps(isDisabled); + const isSelected = selectedKeyIds.includes(id); - {(() => { - // For asymmetric keys, user can export if they have Read OR ExportPrivateKey permission - // For symmetric keys, user needs ExportPrivateKey permission - const isAsymmetricKey = Object.values( - AsymmetricKeyAlgorithm - ).includes(encryptionAlgorithm as AsymmetricKeyAlgorithm); - const cannotExportKey = isAsymmetricKey - ? cannotExportPrivateKey && cannotReadKey - : cannotExportPrivateKey; + const isAsymmetricKey = Object.values(AsymmetricKeyAlgorithm).includes( + encryptionAlgorithm as AsymmetricKeyAlgorithm + ); + const cannotExportKey = isAsymmetricKey + ? cannotExportPrivateKey && cannotReadKey + : cannotExportPrivateKey; - return ( - -
- handlePopUpOpen("exportKey", cmek)} - icon={} - iconPos="left" - isDisabled={cannotExportKey || isDisabled} - > - Export Key - -
-
- ); - })()} - -
- handlePopUpOpen("upsertKey", cmek)} - icon={} - iconPos="left" - isDisabled={cannotEditKey} - > - Edit Key - -
-
- -
- handleDisableCmek(cmek)} - icon={ - - } - iconPos="left" - isDisabled={cannotEditKey} - > - {isDisabled ? "Enable" : "Disable"} Key - -
-
- -
- handlePopUpOpen("deleteKey", cmek)} - icon={} - iconPos="left" - isDisabled={cannotDeleteKey} - > - Delete Key - -
+ return ( + setCopyCipherText("")} + > + + { + e.stopPropagation(); + setSelectedKeyIds((prev) => + isSelected ? prev.filter((k) => k !== id) : [...prev, id] + ); + }} + /> + + +
+ {name} + {description && ( + + + + + {description} - - -
-
+ )} +
+ + +
+ {id} + { + navigator.clipboard.writeText(id); + setCopyCipherText("Copied"); + }} + > + + +
+
+ +
+ {kmsKeyUsageOptions[keyUsage].label} + + + + + + {kmsKeyUsageOptions[keyUsage].tooltip} + + +
+
+ + {encryptionAlgorithm} + + + {label} + + {version} + +
+ + + + + + + + {keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && ( + <> + handlePopUpOpen("encryptData", cmek)} + isDisabled={cannotEncryptData || isDisabled} + > + + Encrypt Data + + handlePopUpOpen("decryptData", cmek)} + isDisabled={cannotDecryptData || isDisabled} + > + + Decrypt Data + + + )} + {keyUsage === KmsKeyUsage.SIGN_VERIFY && ( + <> + handlePopUpOpen("signData", cmek)} + isDisabled={cannotSignData || isDisabled} + > + + Sign Data + + handlePopUpOpen("verifyData", cmek)} + isDisabled={cannotVerifyData || isDisabled} + > + + Verify Data + + + )} + handlePopUpOpen("exportKey", cmek)} + isDisabled={cannotExportKey || isDisabled} + > + + Export Key + + handlePopUpOpen("upsertKey", cmek)} + isDisabled={cannotEditKey} + > + + Edit Key + + handleDisableCmek(cmek)} + isDisabled={cannotEditKey} + > + + {isDisabled ? "Enable" : "Disable"} Key + + handlePopUpOpen("deleteKey", cmek)} + isDisabled={cannotDeleteKey} + variant="danger" + > + + Delete Key + + + +
+
+ + ); + })} + + + )} + {!isPending && totalCount > 0 && ( - setPage(newPage)} + onChangePage={(newPage) => { + setPage(newPage); + setSelectedKeyIds([]); + }} onChangePerPage={handlePerPageChange} /> )} - {!isPending && keys.length === 0 && ( - 0 - ? "No keys match search filter" - : "No keys have been added to this project" - } - icon={faKey} - /> - )} - - handlePopUpToggle("deleteKey", isOpen)} - cmek={popUp.deleteKey.data as TCmek} - /> - handlePopUpToggle("upsertKey", isOpen)} - cmek={popUp.upsertKey.data as TCmek | null} - /> - handlePopUpToggle("encryptData", isOpen)} - cmek={popUp.encryptData.data as TCmek} - /> - handlePopUpToggle("decryptData", isOpen)} - cmek={popUp.decryptData.data as TCmek} - /> - handlePopUpToggle("signData", isOpen)} - cmek={popUp.signData.data as TCmek} - /> - handlePopUpToggle("verifyData", isOpen)} - cmek={popUp.verifyData.data as TCmek} - /> - handlePopUpToggle("exportKey", isOpen)} - cmek={popUp.exportKey.data as TCmek} - /> -
+ + + + handlePopUpToggle("deleteKey", isOpen)} + cmek={popUp.deleteKey.data as TCmek} + /> + handlePopUpToggle("upsertKey", isOpen)} + cmek={popUp.upsertKey.data as TCmek | null} + /> + handlePopUpToggle("encryptData", isOpen)} + cmek={popUp.encryptData.data as TCmek} + /> + handlePopUpToggle("decryptData", isOpen)} + cmek={popUp.decryptData.data as TCmek} + /> + handlePopUpToggle("signData", isOpen)} + cmek={popUp.signData.data as TCmek} + /> + handlePopUpToggle("verifyData", isOpen)} + cmek={popUp.verifyData.data as TCmek} + /> + handlePopUpToggle("exportKey", isOpen)} + cmek={popUp.exportKey.data as TCmek} + /> ); }; diff --git a/frontend/src/pages/kms/OverviewPage/components/jsonExport.ts b/frontend/src/pages/kms/OverviewPage/components/jsonExport.ts new file mode 100644 index 00000000000..c680b2a9320 --- /dev/null +++ b/frontend/src/pages/kms/OverviewPage/components/jsonExport.ts @@ -0,0 +1,49 @@ +import { KmsKeyUsage, TCmekBulkExportedKey } from "@app/hooks/api/cmeks/types"; + +type CmekExportEntry = + | { + name: string; + keyType: "encrypt-decrypt"; + algorithm: string; + keyMaterial: string; + } + | { + name: string; + keyType: "sign-verify"; + algorithm: string; + privateKey: string; + publicKey: string; + }; + +export const cmekKeysToExportJSON = (keys: TCmekBulkExportedKey[]): CmekExportEntry[] => { + return keys.map((key) => { + if (key.keyUsage === KmsKeyUsage.SIGN_VERIFY) { + return { + name: key.name, + keyType: "sign-verify" as const, + algorithm: key.algorithm, + privateKey: key.privateKey, + publicKey: key.publicKey ?? "" + }; + } + + return { + name: key.name, + keyType: "encrypt-decrypt" as const, + algorithm: key.algorithm, + keyMaterial: key.privateKey + }; + }); +}; + +export const downloadJSON = (data: unknown, filename: string) => { + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json;charset=utf-8;" + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +}; From 43518493cd731c9ce33a02c2cfc38ee25bec9be1 Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Fri, 17 Apr 2026 16:34:58 -0300 Subject: [PATCH 02/10] refactor: rename CMEK bulk export event type and enhance error handling - Updated the event type for bulk exporting private keys to improve clarity. - Enhanced error handling in the KMS service to provide more informative messages for missing keys and key material. - Refactored frontend components to utilize the new FileSaver library for JSON export functionality. --- backend/src/ee/services/audit-log/audit-log-types.ts | 4 ++-- backend/src/server/routes/v1/cmek-router.ts | 2 +- backend/src/services/cmek/cmek-service.ts | 12 +++++++++++- frontend/src/hooks/api/cmeks/types.ts | 4 ++-- .../pages/kms/OverviewPage/components/jsonExport.ts | 9 +++------ 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 300c547d644..0b13b2a71ba 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -473,7 +473,7 @@ export enum EventType { CMEK_LIST_SIGNING_ALGORITHMS = "cmek-list-signing-algorithms", CMEK_GET_PUBLIC_KEY = "cmek-get-public-key", CMEK_GET_PRIVATE_KEY = "cmek-get-private-key", - CMEK_BULK_GET_PRIVATE_KEYS = "cmek-bulk-export-private-keys", + CMEK_BULK_EXPORT_PRIVATE_KEYS = "cmek-bulk-export-private-keys", UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping", GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping", @@ -3650,7 +3650,7 @@ interface CmekGetPrivateKeyEvent { } interface CmekBulkGetPrivateKeysEvent { - type: EventType.CMEK_BULK_GET_PRIVATE_KEYS; + type: EventType.CMEK_BULK_EXPORT_PRIVATE_KEYS; metadata: { keyIds: string[]; keyNames: string[]; diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index c59b51838f1..9c7ee9a5a93 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -561,7 +561,7 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { ...req.auditLogInfo, projectId, event: { - type: EventType.CMEK_BULK_GET_PRIVATE_KEYS, + type: EventType.CMEK_BULK_EXPORT_PRIVATE_KEYS, metadata: { keyIds: keys.map((k) => k.keyId), keyNames: keys.map((k) => k.name) diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index e3c45127f3b..c525eb15bca 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -326,6 +326,12 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC if (keys.length === 0) throw new NotFoundError({ message: "No keys found for the provided IDs" }); + if (keys.length !== keyIds.length) { + const foundIds = new Set(keys.map((k) => k.id)); + const missingIds = keyIds.filter((id) => !foundIds.has(id)); + throw new NotFoundError({ message: `Keys not found for IDs: ${missingIds.join(", ")}` }); + } + const projectIds = new Set(); for (const key of keys) { if (!key.projectId || key.isReserved) @@ -367,12 +373,16 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC publicKey = pubKeyBuffer.toString("base64"); } + if (!materialEntry) { + throw new NotFoundError({ message: `Key material not found for key ID "${key.id}"` }); + } + return { keyId: key.id, name: key.name, keyUsage: key.keyUsage, algorithm: key.encryptionAlgorithm, - privateKey: materialEntry!.keyMaterial.toString("base64"), + privateKey: materialEntry.keyMaterial.toString("base64"), ...(publicKey ? { publicKey } : {}) }; }) diff --git a/frontend/src/hooks/api/cmeks/types.ts b/frontend/src/hooks/api/cmeks/types.ts index 5163cd9e25b..35ff6a01402 100644 --- a/frontend/src/hooks/api/cmeks/types.ts +++ b/frontend/src/hooks/api/cmeks/types.ts @@ -99,8 +99,8 @@ export type TCmekBulkExportPrivateKeysDTO = { export type TCmekBulkExportedKey = { keyId: string; name: string; - keyUsage: string; - algorithm: string; + keyUsage: KmsKeyUsage; + algorithm: AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm; privateKey: string; publicKey?: string; }; diff --git a/frontend/src/pages/kms/OverviewPage/components/jsonExport.ts b/frontend/src/pages/kms/OverviewPage/components/jsonExport.ts index c680b2a9320..d6a4eb1819e 100644 --- a/frontend/src/pages/kms/OverviewPage/components/jsonExport.ts +++ b/frontend/src/pages/kms/OverviewPage/components/jsonExport.ts @@ -1,3 +1,5 @@ +import FileSaver from "file-saver"; + import { KmsKeyUsage, TCmekBulkExportedKey } from "@app/hooks/api/cmeks/types"; type CmekExportEntry = @@ -40,10 +42,5 @@ export const downloadJSON = (data: unknown, filename: string) => { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - link.click(); - URL.revokeObjectURL(url); + FileSaver.saveAs(blob, filename); }; From d2c1e64ae662941d6aa9b3be08398bc839a48669 Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Fri, 17 Apr 2026 16:40:12 -0300 Subject: [PATCH 03/10] feat: limit bulk export selection to 100 keys and enhance user feedback - Added a check to prevent exporting more than 100 keys at once, displaying an error notification if the limit is exceeded. - Updated the logic for selecting keys to ensure that the selected key IDs do not exceed the 100-key limit during selection. --- .../kms/OverviewPage/components/CmekTable.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index c775fc7089d..70ed100c656 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -181,6 +181,11 @@ export const CmekTable = () => { }; const handleBulkExport = async () => { + if (selectedKeyIds.length > 100) { + createNotification({ text: "Cannot export more than 100 keys at once", type: "error" }); + return; + } + const { keys: exportedKeys } = await bulkExportMutation.mutateAsync({ keyIds: selectedKeyIds }); @@ -354,9 +359,10 @@ export const CmekTable = () => { prev.filter((id) => !keys.find((k) => k.id === id)) ); } else { - setSelectedKeyIds((prev) => [ - ...new Set([...prev, ...keys.map((k) => k.id)]) - ]); + setSelectedKeyIds((prev) => { + const merged = [...new Set([...prev, ...keys.map((k) => k.id)])]; + return merged.slice(0, 100); + }); } }} /> @@ -425,9 +431,11 @@ export const CmekTable = () => { variant="project" onClick={(e) => { e.stopPropagation(); - setSelectedKeyIds((prev) => - isSelected ? prev.filter((k) => k !== id) : [...prev, id] - ); + setSelectedKeyIds((prev) => { + if (isSelected) return prev.filter((k) => k !== id); + if (prev.length >= 100) return prev; + return [...prev, id]; + }); }} /> From 524aaa02c8fb93caefa9fe2580b6d77ecdd30459 Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Fri, 17 Apr 2026 16:59:08 -0300 Subject: [PATCH 04/10] refactor: improve CmekTable component and user feedback - Wrapped the export button in a span for better styling control. - Enhanced tooltip content to provide clearer feedback based on user permissions. - Removed unnecessary state reset on search input change. - Simplified page change handling by directly setting the page state without resetting selected keys. --- .../kms/OverviewPage/components/CmekTable.tsx | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index 70ed100c656..fdb0d48d316 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -267,19 +267,24 @@ export const CmekTable = () => { {(isAllowed) => ( - + + + - Export all selected keys as a JSON file + + {isAllowed + ? "Export all selected keys as a JSON file" + : "You don't have permission to export keys"} + )} @@ -320,7 +325,6 @@ export const CmekTable = () => { value={search} onChange={(e) => { setSearch(e.target.value); - setSelectedKeyIds([]); }} placeholder="Search keys by name..." /> @@ -431,11 +435,16 @@ export const CmekTable = () => { variant="project" onClick={(e) => { e.stopPropagation(); - setSelectedKeyIds((prev) => { - if (isSelected) return prev.filter((k) => k !== id); - if (prev.length >= 100) return prev; - return [...prev, id]; - }); + if (!isSelected && selectedKeyIds.length >= 100) { + createNotification({ + text: "Cannot select more than 100 keys at once", + type: "error" + }); + return; + } + setSelectedKeyIds((prev) => + isSelected ? prev.filter((k) => k !== id) : [...prev, id] + ); }} /> @@ -593,10 +602,7 @@ export const CmekTable = () => { count={totalCount} page={page} perPage={perPage} - onChangePage={(newPage) => { - setPage(newPage); - setSelectedKeyIds([]); - }} + onChangePage={setPage} onChangePerPage={handlePerPageChange} /> )} From 5405fd081b0af7994988b6a3aa75364da197ccb0 Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Fri, 17 Apr 2026 17:39:30 -0300 Subject: [PATCH 05/10] feat: enhance KMS service and documentation for bulk key export - Updated the KMS service to handle unique key IDs for bulk retrieval, improving error handling for missing keys. - Added a new API endpoint for bulk exporting private keys and corresponding documentation. - Refactored the CmekTable component to utilize the new InfoIcon for tooltips, enhancing user experience. --- backend/src/services/cmek/cmek-service.ts | 24 +++++++++++-------- .../kms/keys/bulk-export-private-keys.mdx | 4 ++++ docs/docs.json | 3 ++- .../kms/OverviewPage/components/CmekTable.tsx | 14 ++++------- 4 files changed, 24 insertions(+), 21 deletions(-) create mode 100644 docs/api-reference/endpoints/kms/keys/bulk-export-private-keys.mdx diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index c525eb15bca..e5b83158ac6 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -3,7 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign"; +import { AsymmetricKeyAlgorithm, SigningAlgorithm, signingService } from "@app/lib/crypto/sign"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; @@ -322,13 +322,14 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC const bulkGetPrivateKeys = async ({ keyIds }: TCmekBulkGetPrivateKeysDTO, actor: OrgServiceActor) => { if (keyIds.length === 0) throw new BadRequestError({ message: "At least one key ID is required" }); - const keys = await kmsDAL.findCmeksByIds(keyIds); + const uniqueKeyIds = [...new Set(keyIds)]; + const keys = await kmsDAL.findCmeksByIds(uniqueKeyIds); if (keys.length === 0) throw new NotFoundError({ message: "No keys found for the provided IDs" }); - if (keys.length !== keyIds.length) { + if (keys.length !== uniqueKeyIds.length) { const foundIds = new Set(keys.map((k) => k.id)); - const missingIds = keyIds.filter((id) => !foundIds.has(id)); + const missingIds = uniqueKeyIds.filter((id) => !foundIds.has(id)); throw new NotFoundError({ message: `Keys not found for IDs: ${missingIds.join(", ")}` }); } @@ -358,25 +359,28 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC ProjectPermissionSub.Cmek ); - const bulkMaterials = await kmsService.getBulkKeyMaterial({ kmsIds: keyIds }); + const bulkMaterials = await kmsService.getBulkKeyMaterial({ kmsIds: uniqueKeyIds }); const result = await Promise.all( keys.map(async (key) => { const materialEntry = bulkMaterials.find((m) => m.kmsId === key.id); + + if (!materialEntry) { + throw new NotFoundError({ message: `Key material not found for key ID "${key.id}"` }); + } + const isAsymmetric = Object.values(AsymmetricKeyAlgorithm).includes( key.encryptionAlgorithm as AsymmetricKeyAlgorithm ); let publicKey: string | undefined; if (isAsymmetric) { - const pubKeyBuffer = await kmsService.getPublicKey({ kmsId: key.id }); + const pubKeyBuffer = signingService( + key.encryptionAlgorithm as AsymmetricKeyAlgorithm + ).getPublicKeyFromPrivateKey(materialEntry.keyMaterial); publicKey = pubKeyBuffer.toString("base64"); } - if (!materialEntry) { - throw new NotFoundError({ message: `Key material not found for key ID "${key.id}"` }); - } - return { keyId: key.id, name: key.name, diff --git a/docs/api-reference/endpoints/kms/keys/bulk-export-private-keys.mdx b/docs/api-reference/endpoints/kms/keys/bulk-export-private-keys.mdx new file mode 100644 index 00000000000..e6725cea20b --- /dev/null +++ b/docs/api-reference/endpoints/kms/keys/bulk-export-private-keys.mdx @@ -0,0 +1,4 @@ +--- +title: "Bulk Export Private Keys" +openapi: "POST /api/v1/kms/keys/bulk-export-private-keys" +--- diff --git a/docs/docs.json b/docs/docs.json index f00db502ad8..c6e91b1f6f5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -3239,7 +3239,8 @@ "api-reference/endpoints/kms/keys/update", "api-reference/endpoints/kms/keys/delete", "api-reference/endpoints/kms/keys/public-key", - "api-reference/endpoints/kms/keys/private-key" + "api-reference/endpoints/kms/keys/private-key", + "api-reference/endpoints/kms/keys/bulk-export-private-keys" ] }, { diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index fdb0d48d316..4f3e1454edf 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -8,7 +8,6 @@ import { faEdit, faEllipsis, faFileSignature, - faInfoCircle, faLock, faLockOpen, faPlus, @@ -16,7 +15,7 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { motion } from "framer-motion"; -import { ChevronDownIcon, SearchIcon } from "lucide-react"; +import { ChevronDownIcon, InfoIcon, SearchIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; @@ -425,6 +424,7 @@ export const CmekTable = () => { return ( setCopyCipherText("")} > @@ -454,10 +454,7 @@ export const CmekTable = () => { {description && ( - + {description} @@ -486,10 +483,7 @@ export const CmekTable = () => { {kmsKeyUsageOptions[keyUsage].label} - + {kmsKeyUsageOptions[keyUsage].tooltip} From e2155b8836e73d98ab0c74415702501ee651ba85 Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Wed, 22 Apr 2026 12:41:33 -0300 Subject: [PATCH 06/10] fixing types --- .../src/ee/services/license/license-types.ts | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index c7446ba201a..bd556a50658 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -35,43 +35,43 @@ export type TFeatureSet = { _id: null; slug: string | null; tier: -1; - workspaceLimit: null | number; + workspaceLimit: null; workspacesUsed: number; - dynamicSecret: boolean; + dynamicSecret: false; memberLimit: null; membersUsed: number; - identityLimit: null | number; + identityLimit: null; identitiesUsed: number; - subOrganization: boolean; - environmentLimit: null | number; + subOrganization: false; + environmentLimit: null; environmentsUsed: 0; secretVersioning: true; - pitRecovery: boolean; - ipAllowlisting: boolean; - rbac: boolean; - customRateLimits: boolean; - customAlerts: boolean; - auditLogs: boolean; - auditLogsRetentionDays: number; - auditLogStreams: boolean; + pitRecovery: false; + ipAllowlisting: false; + rbac: false; + customRateLimits: false; + customAlerts: false; + auditLogs: false; + auditLogsRetentionDays: 0; + auditLogStreams: false; auditLogStreamLimit: 3; - githubOrgSync: boolean; - samlSSO: boolean; - enforceGoogleSSO: boolean; - hsm: boolean; - oidcSSO: boolean; - secretAccessInsights: boolean; - scim: boolean; - ldap: boolean; - groups: boolean; + githubOrgSync: false; + samlSSO: false; + enforceGoogleSSO: false; + hsm: false; + oidcSSO: false; + secretAccessInsights: false; + scim: false; + ldap: false; + groups: false; status: null; trial_end: null; has_used_trial: true; - secretApproval: boolean; - secretRotation: boolean; + secretApproval: false; + secretRotation: false; caCrl: false; - instanceUserManagement: boolean; - externalKms: boolean; + instanceUserManagement: false; + externalKms: false; rateLimits: { readLimit: number; writeLimit: number; @@ -91,9 +91,9 @@ export type TFeatureSet = { enterpriseAppConnections: false; machineIdentityAuthTemplates: false; pkiLegacyTemplates: false; - fips: boolean; - eventSubscriptions: boolean; - secretShareExternalBranding: boolean; + fips: false; + eventSubscriptions: false; + secretShareExternalBranding: false; }; export type TOrgPlansTableDTO = { From 126913bfe2127d41ad7f45eb5b8fc153c2572b64 Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Wed, 22 Apr 2026 15:04:19 -0300 Subject: [PATCH 07/10] refactor: replace unstable components with stable counterparts in CmekTable --- .../kms/OverviewPage/components/CmekTable.tsx | 222 +++++++++--------- 1 file changed, 107 insertions(+), 115 deletions(-) diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index 4f3e1454edf..b4e9d5fc878 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -24,38 +24,38 @@ import { Spinner } from "@app/components/v2"; import { Badge, Button, + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, Checkbox, DocumentationLinkBadge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, + IconButton, InputGroup, InputGroupAddon, InputGroupInput, + Pagination, Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, TBadgeProps, Tooltip, TooltipContent, - TooltipTrigger, - UnstableCard, - UnstableCardAction, - UnstableCardContent, - UnstableCardDescription, - UnstableCardHeader, - UnstableCardTitle, - UnstableDropdownMenu, - UnstableDropdownMenuContent, - UnstableDropdownMenuItem, - UnstableDropdownMenuTrigger, - UnstableEmpty, - UnstableEmptyDescription, - UnstableEmptyHeader, - UnstableEmptyTitle, - UnstableIconButton, - UnstablePagination, - UnstableTable, - UnstableTableBody, - UnstableTableCell, - UnstableTableHead, - UnstableTableHeader, - UnstableTableRow + TooltipTrigger } from "@app/components/v3"; import { ProjectPermissionActions, @@ -290,16 +290,14 @@ export const CmekTable = () => {
- - - + + + Keys - - - Manage keys and perform cryptographic operations. - - + + Manage keys and perform cryptographic operations. + {(isAllowed) => ( )} - - - + + +
@@ -332,25 +330,25 @@ export const CmekTable = () => {
{!isPending && keys.length === 0 ? ( - - - + + + {debouncedSearch.trim().length > 0 ? "No keys match search filter" : "No keys have been added to this project"} - - + + {debouncedSearch.trim().length > 0 ? "Try a different search term." : "Add a key to get started."} - - - + + + ) : ( - - - - + + + + { } }} /> - - + + Name { orderDirection === OrderByDirection.DESC && "rotate-180" )} /> - - Key ID - Key Usage - Algorithm - Status - Version - - - - + + Key ID + Key Usage + Algorithm + Status + Version + + + + {isPending && Array.from({ length: 5 }).map((_, i) => ( // eslint-disable-next-line react/no-array-index-key - + {Array.from({ length: 8 }).map((__, j) => ( // eslint-disable-next-line react/no-array-index-key - + - + ))} - + ))} {!isPending && keys.map((cmek) => { @@ -422,13 +420,13 @@ export const CmekTable = () => { : cannotExportPrivateKey; return ( - setCopyCipherText("")} > - + { ); }} /> - - + +
{name} {description && ( @@ -460,11 +458,11 @@ export const CmekTable = () => { )}
-
- + +
{id} - { }} > - +
-
- + +
{kmsKeyUsageOptions[keyUsage].label} @@ -490,78 +488,72 @@ export const CmekTable = () => {
-
- - {encryptionAlgorithm} - - + + {encryptionAlgorithm} + {label} - - {version} - + + {version} +
- - - + + + - - - + + + {keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && ( <> - handlePopUpOpen("encryptData", cmek)} isDisabled={cannotEncryptData || isDisabled} > Encrypt Data - - + handlePopUpOpen("decryptData", cmek)} isDisabled={cannotDecryptData || isDisabled} > Decrypt Data - + )} {keyUsage === KmsKeyUsage.SIGN_VERIFY && ( <> - handlePopUpOpen("signData", cmek)} isDisabled={cannotSignData || isDisabled} > Sign Data - - + handlePopUpOpen("verifyData", cmek)} isDisabled={cannotVerifyData || isDisabled} > Verify Data - + )} - handlePopUpOpen("exportKey", cmek)} isDisabled={cannotExportKey || isDisabled} > Export Key - - + handlePopUpOpen("upsertKey", cmek)} isDisabled={cannotEditKey} > Edit Key - - + handleDisableCmek(cmek)} isDisabled={cannotEditKey} > @@ -570,28 +562,28 @@ export const CmekTable = () => { className="mr-2" /> {isDisabled ? "Enable" : "Disable"} Key - - + handlePopUpOpen("deleteKey", cmek)} isDisabled={cannotDeleteKey} variant="danger" > Delete Key - - - + + +
-
-
+ + ); })} - - +
+
)} {!isPending && totalCount > 0 && ( - { onChangePerPage={handlePerPageChange} /> )} -
-
+ + Date: Wed, 22 Apr 2026 18:40:49 -0300 Subject: [PATCH 08/10] refactor: unify key metadata structure in audit log and cmek service; enhance CmekTable for better key selection handling --- .../ee/services/audit-log/audit-log-types.ts | 3 +- backend/src/server/routes/v1/cmek-router.ts | 3 +- backend/src/services/cmek/cmek-service.ts | 53 +++++++------- .../kms/OverviewPage/components/CmekTable.tsx | 72 +++++++++++++------ 4 files changed, 76 insertions(+), 55 deletions(-) diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 872ea08448d..f2b56ec2d67 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -3662,8 +3662,7 @@ interface CmekGetPrivateKeyEvent { interface CmekBulkGetPrivateKeysEvent { type: EventType.CMEK_BULK_EXPORT_PRIVATE_KEYS; metadata: { - keyIds: string[]; - keyNames: string[]; + keys: { keyId: string; name: string }[]; }; } diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index 9c7ee9a5a93..0a6ded8679e 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -563,8 +563,7 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { event: { type: EventType.CMEK_BULK_EXPORT_PRIVATE_KEYS, metadata: { - keyIds: keys.map((k) => k.keyId), - keyNames: keys.map((k) => k.name) + keys: keys.map((k) => ({ keyId: k.keyId, name: k.name })) } } }); diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index e5b83158ac6..0a176ebf212 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -359,38 +359,35 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC ProjectPermissionSub.Cmek ); - const bulkMaterials = await kmsService.getBulkKeyMaterial({ kmsIds: uniqueKeyIds }); + const bulkMaterials = await kmsService.getBulkKeyMaterial({ kmsIds: keys.map((k) => k.id) }); - const result = await Promise.all( - keys.map(async (key) => { - const materialEntry = bulkMaterials.find((m) => m.kmsId === key.id); + const materialByKmsId = new Map(bulkMaterials.map((m) => [m.kmsId, m])); + const asymmetricAlgorithms = new Set(Object.values(AsymmetricKeyAlgorithm)); - if (!materialEntry) { - throw new NotFoundError({ message: `Key material not found for key ID "${key.id}"` }); - } + const result = keys.map((key) => { + const materialEntry = materialByKmsId.get(key.id); - const isAsymmetric = Object.values(AsymmetricKeyAlgorithm).includes( + if (!materialEntry) { + throw new NotFoundError({ message: `Key material not found for key ID "${key.id}"` }); + } + + let publicKey: string | undefined; + if (asymmetricAlgorithms.has(key.encryptionAlgorithm)) { + const pubKeyBuffer = signingService( key.encryptionAlgorithm as AsymmetricKeyAlgorithm - ); - - let publicKey: string | undefined; - if (isAsymmetric) { - const pubKeyBuffer = signingService( - key.encryptionAlgorithm as AsymmetricKeyAlgorithm - ).getPublicKeyFromPrivateKey(materialEntry.keyMaterial); - publicKey = pubKeyBuffer.toString("base64"); - } - - return { - keyId: key.id, - name: key.name, - keyUsage: key.keyUsage, - algorithm: key.encryptionAlgorithm, - privateKey: materialEntry.keyMaterial.toString("base64"), - ...(publicKey ? { publicKey } : {}) - }; - }) - ); + ).getPublicKeyFromPrivateKey(materialEntry.keyMaterial); + publicKey = pubKeyBuffer.toString("base64"); + } + + return { + keyId: key.id, + name: key.name, + keyUsage: key.keyUsage, + algorithm: key.encryptionAlgorithm, + privateKey: materialEntry.keyMaterial.toString("base64"), + ...(publicKey ? { publicKey } : {}) + }; + }); return { keys: result, projectId }; }; diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index b4e9d5fc878..509ec44d79c 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { faCancel, faCheck, @@ -144,8 +144,15 @@ export const CmekTable = () => { const [selectedKeyIds, setSelectedKeyIds] = useState([]); - const isPageSelected = keys.length > 0 && keys.every((k) => selectedKeyIds.includes(k.id)); - const isPageIndeterminate = !isPageSelected && keys.some((k) => selectedKeyIds.includes(k.id)); + useEffect(() => { + setSelectedKeyIds([]); + }, [page]); + + const selectableKeys = keys.filter((k) => !k.isDisabled); + const isPageSelected = + selectableKeys.length > 0 && selectableKeys.every((k) => selectedKeyIds.includes(k.id)); + const isPageIndeterminate = + !isPageSelected && selectableKeys.some((k) => selectedKeyIds.includes(k.id)); const [, isCopyingCiphertext, setCopyCipherText] = useTimedReset({ initialState: "", @@ -353,15 +360,18 @@ export const CmekTable = () => { id="cmek-page-select" isChecked={isPageSelected || isPageIndeterminate} isIndeterminate={isPageIndeterminate} + isDisabled={selectableKeys.length === 0} variant="project" onCheckedChange={() => { if (isPageSelected) { setSelectedKeyIds((prev) => - prev.filter((id) => !keys.find((k) => k.id === id)) + prev.filter((id) => !selectableKeys.find((k) => k.id === id)) ); } else { setSelectedKeyIds((prev) => { - const merged = [...new Set([...prev, ...keys.map((k) => k.id)])]; + const merged = [ + ...new Set([...prev, ...selectableKeys.map((k) => k.id)]) + ]; return merged.slice(0, 100); }); } @@ -427,24 +437,40 @@ export const CmekTable = () => { onMouseLeave={() => setCopyCipherText("")} > - { - e.stopPropagation(); - if (!isSelected && selectedKeyIds.length >= 100) { - createNotification({ - text: "Cannot select more than 100 keys at once", - type: "error" - }); - return; - } - setSelectedKeyIds((prev) => - isSelected ? prev.filter((k) => k !== id) : [...prev, id] - ); - }} - /> + {isDisabled ? ( + + + + + + + Disabled keys cannot be exported + + ) : ( + { + e.stopPropagation(); + if (!isSelected && selectedKeyIds.length >= 100) { + createNotification({ + text: "Cannot select more than 100 keys at once", + type: "error" + }); + return; + } + setSelectedKeyIds((prev) => + isSelected ? prev.filter((k) => k !== id) : [...prev, id] + ); + }} + /> + )}
From 49a2e918f26eaab01c33844b2b407956a0d842e4 Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Wed, 22 Apr 2026 19:49:29 -0300 Subject: [PATCH 09/10] enhance: improve search functionality in KMS key retrieval; update placeholder in CmekTable for clarity --- backend/src/services/kms/kms-key-dal.ts | 5 ++++- frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/services/kms/kms-key-dal.ts b/backend/src/services/kms/kms-key-dal.ts index c7554c9c4f4..132c62bfbb1 100644 --- a/backend/src/services/kms/kms-key-dal.ts +++ b/backend/src/services/kms/kms-key-dal.ts @@ -192,7 +192,10 @@ export const kmskeyDALFactory = (db: TDbClient) => { .where("projectId", projectId) .where((qb) => { if (search) { - void qb.whereILike("name", `%${search}%`); + const pattern = `%${search}%`; + void qb + .whereILike(`${TableName.KmsKey}.name`, pattern) + .orWhereRaw(`?? ::text ILIKE ?`, [`${TableName.KmsKey}.id`, pattern]); } }) .where(`${TableName.KmsKey}.isReserved`, false) diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index 509ec44d79c..67224a6d07f 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -330,7 +330,7 @@ export const CmekTable = () => { onChange={(e) => { setSearch(e.target.value); }} - placeholder="Search keys by name..." + placeholder="Search keys by name or ID..." /> {isFetching && } From 92c1dd1ffcc21de7c2101d9e9a4c9e067dbcf8bc Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Wed, 22 Apr 2026 21:41:55 -0300 Subject: [PATCH 10/10] feat: enhance CmekTable and DeleteCmekModal to manage selected key IDs on deletion --- .../src/pages/kms/OverviewPage/components/CmekTable.tsx | 6 ++++++ .../pages/kms/OverviewPage/components/DeleteCmekModal.tsx | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index 67224a6d07f..ecee0beb7a5 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -180,6 +180,9 @@ export const CmekTable = () => { const handleDisableCmek = async ({ id: keyId, isDisabled }: TCmek) => { await updateCmek.mutateAsync({ keyId, projectId, isDisabled: !isDisabled }); + if (!isDisabled) { + setSelectedKeyIds((prev) => prev.filter((id) => id !== keyId)); + } createNotification({ text: `Key successfully ${isDisabled ? "enabled" : "disabled"}`, type: "success" @@ -625,6 +628,9 @@ export const CmekTable = () => { isOpen={popUp.deleteKey.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("deleteKey", isOpen)} cmek={popUp.deleteKey.data as TCmek} + onDeleted={(deletedKeyId) => + setSelectedKeyIds((prev) => prev.filter((id) => id !== deletedKeyId)) + } /> void; + onDeleted?: (keyId: string) => void; }; -export const DeleteCmekModal = ({ isOpen, onOpenChange, cmek }: Props) => { +export const DeleteCmekModal = ({ isOpen, onOpenChange, cmek, onDeleted }: Props) => { const deleteCmek = useDeleteCmek(); if (!cmek) return null; @@ -26,6 +27,7 @@ export const DeleteCmekModal = ({ isOpen, onOpenChange, cmek }: Props) => { type: "success" }); + onDeleted?.(keyId); onOpenChange(false); };