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 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 680fcff3c92d7d213b27cefe02f7458a318bc4ae Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Fri, 17 Apr 2026 23:21:11 -0300 Subject: [PATCH 6/8] feat: implement bulk import functionality for KMS keys - Added a new API endpoint for bulk importing KMS keys with validation for key types and algorithms. - Introduced corresponding types and interfaces for handling bulk import requests and responses. - Updated the audit log to track bulk import events. - Enhanced the CmekTable component to include an import keys option in the dropdown menu. - Created a new CmekBulkImportModal component for user interaction during key import. --- .../ee/services/audit-log/audit-log-types.ts | 10 + backend/src/server/routes/v1/cmek-router.ts | 89 ++++ backend/src/services/cmek/cmek-service.ts | 57 ++- backend/src/services/cmek/cmek-types.ts | 12 + backend/src/services/kms/kms-service.ts | 16 +- backend/src/services/kms/kms-types.ts | 2 +- frontend/src/hooks/api/cmeks/mutations.tsx | 19 + frontend/src/hooks/api/cmeks/types.ts | 17 + .../components/CmekBulkImportModal.tsx | 436 ++++++++++++++++++ .../kms/OverviewPage/components/CmekTable.tsx | 74 ++- 10 files changed, 714 insertions(+), 18 deletions(-) create mode 100644 frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx 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 0b13b2a71ba..775e9fb7707 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -474,6 +474,7 @@ export enum EventType { CMEK_GET_PUBLIC_KEY = "cmek-get-public-key", CMEK_GET_PRIVATE_KEY = "cmek-get-private-key", CMEK_BULK_EXPORT_PRIVATE_KEYS = "cmek-bulk-export-private-keys", + CMEK_BULK_IMPORT_KEYS = "cmek-bulk-import-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", @@ -3657,6 +3658,14 @@ interface CmekBulkGetPrivateKeysEvent { }; } +interface CmekBulkImportKeysEvent { + type: EventType.CMEK_BULK_IMPORT_KEYS; + metadata: { + keyNames: string[]; + projectId: string; + }; +} + interface GetExternalGroupOrgRoleMappingsEvent { type: EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS; metadata?: Record; // not needed, based off orgId @@ -6298,6 +6307,7 @@ export type Event = | CmekGetPublicKeyEvent | CmekGetPrivateKeyEvent | CmekBulkGetPrivateKeysEvent + | CmekBulkImportKeysEvent | GetExternalGroupOrgRoleMappingsEvent | UpdateExternalGroupOrgRoleMappingsEvent | GetProjectTemplatesEvent diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index 9c7ee9a5a93..2f0062ba369 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -518,6 +518,95 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/keys/bulk-import", + config: { rateLimit: writeLimit }, + schema: { + hide: false, + operationId: "bulkImportKmsKeys", + tags: [ApiDocsTags.KmsKeys], + description: "Bulk import KMS keys with provided key material into a project.", + body: z.object({ + projectId: z.string().uuid(), + keys: z + .array( + z + .object({ + name: keyNameSchema, + keyUsage: z.nativeEnum(KmsKeyUsage), + encryptionAlgorithm: z.enum(AllowedEncryptionKeyAlgorithms), + keyMaterial: z.string().min(1) + }) + .superRefine((data, ctx) => { + if ( + data.keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && + !Object.values(SymmetricKeyAlgorithm).includes(data.encryptionAlgorithm as SymmetricKeyAlgorithm) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `encryptionAlgorithm must be a symmetric algorithm for encrypt-decrypt keys` + }); + } + if ( + data.keyUsage === KmsKeyUsage.SIGN_VERIFY && + !Object.values(AsymmetricKeyAlgorithm).includes(data.encryptionAlgorithm as AsymmetricKeyAlgorithm) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `encryptionAlgorithm must be an asymmetric algorithm for sign-verify keys` + }); + } + }) + ) + .min(1) + .max(100) + }), + response: { + 200: z.object({ + keys: z.array(z.object({ id: z.string(), name: z.string() })), + errors: z.array(z.object({ name: z.string(), message: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + body: { projectId, keys }, + permission + } = req; + + const { keys: importedKeys, errors } = await server.services.cmek.bulkImportKeys( + { + projectId, + keys: keys.map((k) => ({ + name: k.name, + algorithm: k.encryptionAlgorithm as TCmekKeyEncryptionAlgorithm, + keyUsage: k.keyUsage, + keyMaterial: k.keyMaterial + })) + }, + permission + ); + + if (importedKeys.length > 0) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_BULK_IMPORT_KEYS, + metadata: { + keyNames: importedKeys.map((k) => k.name), + projectId + } + } + }); + } + + return { keys: importedKeys, errors }; + } + }); + server.route({ method: "POST", url: "/keys/bulk-export-private-keys", diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index e5b83158ac6..2e758bebc6c 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -9,6 +9,7 @@ import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { TCmekBulkGetPrivateKeysDTO, + TCmekBulkImportKeysDTO, TCmekDecryptDTO, TCmekEncryptDTO, TCmekGetPrivateKeyDTO, @@ -496,6 +497,59 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC }; }; + const bulkImportKeys = async ({ projectId, keys }: TCmekBulkImportKeysDTO, actor: OrgServiceActor) => { + 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.Create, ProjectPermissionSub.Cmek); + + const results = await Promise.allSettled( + keys.map(async (entry): Promise<{ id: string; name: string }> => { + const imported = await kmsService.importKeyMaterial({ + key: Buffer.from(entry.keyMaterial, "base64"), + algorithm: entry.algorithm, + name: entry.name, + isReserved: false, + projectId, + orgId: actor.orgId, + keyUsage: entry.keyUsage + }); + return { id: imported.id, name: imported.name }; + }) + ); + + const importedKeys: { id: string; name: string }[] = []; + const errors: { name: string; message: string }[] = []; + + results.forEach((result, i) => { + const entry = keys[i]; + if (!entry) return; + if (result.status === "fulfilled") { + importedKeys.push(result.value); + } else { + const reason = result.reason as Error; + let message = "Failed to import key"; + if ( + reason instanceof DatabaseError && + (reason.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation + ) { + message = `A key with the name "${entry.name}" already exists in this project`; + } else if (reason instanceof BadRequestError) { + message = reason.message; + } + errors.push({ name: entry.name, message }); + } + }); + + return { keys: importedKeys, errors, projectId }; + }; + return { createCmek, updateCmekById, @@ -510,6 +564,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC listSigningAlgorithms, getPublicKey, getPrivateKey, - bulkGetPrivateKeys + bulkGetPrivateKeys, + bulkImportKeys }; }; diff --git a/backend/src/services/cmek/cmek-types.ts b/backend/src/services/cmek/cmek-types.ts index d1b3267182c..3f986f48a9b 100644 --- a/backend/src/services/cmek/cmek-types.ts +++ b/backend/src/services/cmek/cmek-types.ts @@ -61,6 +61,18 @@ export type TCmekBulkGetPrivateKeysDTO = { keyIds: string[]; }; +export type TCmekBulkImportKeyEntry = { + name: string; + algorithm: TCmekKeyEncryptionAlgorithm; + keyUsage: KmsKeyUsage; + keyMaterial: string; +}; + +export type TCmekBulkImportKeysDTO = { + projectId: string; + keys: TCmekBulkImportKeyEntry[]; +}; + export type TCmekSignDTO = { keyId: string; data: string; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 8ac0b099b99..f7cee7ac7a1 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -402,8 +402,18 @@ export const kmsServiceFactory = ({ { key, algorithm, name, isReserved, projectId, orgId, keyUsage, kmipMetadata }: TImportKeyMaterialDTO, tx?: Knex ) => { - // daniel: currently we only support imports for encrypt/decrypt keys - verifyKeyTypeAndAlgorithm(keyUsage, algorithm, { forceType: KmsKeyUsage.ENCRYPT_DECRYPT }); + verifyKeyTypeAndAlgorithm(keyUsage, algorithm); + + if (keyUsage === KmsKeyUsage.SIGN_VERIFY) { + const { getPublicKeyFromPrivateKey } = signingService(algorithm as AsymmetricKeyAlgorithm); + try { + getPublicKeyFromPrivateKey(key); + } catch { + throw new BadRequestError({ + message: "Invalid private key material. Expected a PKCS8 PEM-encoded private key." + }); + } + } const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); @@ -413,7 +423,7 @@ export const kmsServiceFactory = ({ const kmsDoc = await kmsDAL.create( { name: sanitizedName, - keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT, + keyUsage, orgId, isReserved, projectId, diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index f2c05b6ad89..27c6b457261 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -97,7 +97,7 @@ export type TGetBulkKeyMaterialDTO = { export type TImportKeyMaterialDTO = { key: Buffer; - algorithm: SymmetricKeyAlgorithm; + algorithm: SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm; name?: string; isReserved: boolean; projectId: string; diff --git a/frontend/src/hooks/api/cmeks/mutations.tsx b/frontend/src/hooks/api/cmeks/mutations.tsx index f322ff9e11e..c140b89a41d 100644 --- a/frontend/src/hooks/api/cmeks/mutations.tsx +++ b/frontend/src/hooks/api/cmeks/mutations.tsx @@ -6,6 +6,8 @@ import { cmekKeys } from "@app/hooks/api/cmeks/queries"; import { TCmekBulkExportPrivateKeysDTO, TCmekBulkExportPrivateKeysResponse, + TCmekBulkImportKeysDTO, + TCmekBulkImportKeysResponse, TCmekDecrypt, TCmekDecryptResponse, TCmekEncrypt, @@ -145,3 +147,20 @@ export const useBulkExportCmekPrivateKeys = () => { } }); }; + +export const useBulkImportCmekKeys = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ projectId, keys }: TCmekBulkImportKeysDTO) => { + const { data } = await apiRequest.post( + "/api/v1/kms/keys/bulk-import", + { projectId, keys } + ); + + return data; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: cmekKeys.getCmeksByProjectId({ projectId }) }); + } + }); +}; diff --git a/frontend/src/hooks/api/cmeks/types.ts b/frontend/src/hooks/api/cmeks/types.ts index 35ff6a01402..ff18e9fd564 100644 --- a/frontend/src/hooks/api/cmeks/types.ts +++ b/frontend/src/hooks/api/cmeks/types.ts @@ -109,6 +109,23 @@ export type TCmekBulkExportPrivateKeysResponse = { keys: TCmekBulkExportedKey[]; }; +export type TCmekBulkImportKeyEntry = { + name: string; + keyUsage: KmsKeyUsage; + encryptionAlgorithm: AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm; + keyMaterial: string; +}; + +export type TCmekBulkImportKeysDTO = { + projectId: string; + keys: TCmekBulkImportKeyEntry[]; +}; + +export type TCmekBulkImportKeysResponse = { + keys: { id: string; name: string }[]; + errors: { name: string; message: string }[]; +}; + export enum CmekOrderBy { Name = "name" } diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx new file mode 100644 index 00000000000..2e3610d5e65 --- /dev/null +++ b/frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx @@ -0,0 +1,436 @@ +import { useRef, useState } from "react"; +import { AlertTriangleIcon, UploadIcon } from "lucide-react"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + EmptyMedia, + Tooltip, + TooltipContent, + TooltipTrigger, + UnstableEmpty, + UnstableEmptyDescription, + UnstableEmptyHeader, + UnstableEmptyTitle, + UnstableTable, + UnstableTableBody, + UnstableTableCell, + UnstableTableHead, + UnstableTableHeader, + UnstableTableRow +} from "@app/components/v3"; +import { kmsKeyUsageOptions } from "@app/helpers/kms"; +import { + AsymmetricKeyAlgorithm, + KmsKeyUsage, + SymmetricKeyAlgorithm, + useBulkImportCmekKeys +} from "@app/hooks/api/cmeks"; + +type ParsedKey = { + name: string; + keyType: "encrypt-decrypt" | "sign-verify"; + algorithm: string; + keyMaterial?: string; + privateKey?: string; + publicKey?: string; +}; + +type ValidationError = { + index: number; + message: string; +}; + +const validateEntry = (entry: unknown, index: number): ValidationError | null => { + if (typeof entry !== "object" || entry === null) { + return { index, message: "must be an object" }; + } + const e = entry as Record; + + if (!e.name || typeof e.name !== "string") { + return { index, message: `Entry ${index + 1}: "name" is required` }; + } + if (e.keyType !== "encrypt-decrypt" && e.keyType !== "sign-verify") { + return { + index, + message: '"keyType" must be "encrypt-decrypt" or "sign-verify"' + }; + } + if (!e.algorithm || typeof e.algorithm !== "string") { + return { index, message: '"algorithm" is required' }; + } + if (e.keyType === "encrypt-decrypt") { + const validSymmetric = Object.values(SymmetricKeyAlgorithm) as string[]; + if (!validSymmetric.includes(e.algorithm)) { + return { + index, + message: `"algorithm" must be one of ${validSymmetric.join(", ")} for encrypt-decrypt keys` + }; + } + if (!e.keyMaterial || typeof e.keyMaterial !== "string") { + return { + index, + message: '"keyMaterial" is required for encrypt-decrypt keys' + }; + } + } + if (e.keyType === "sign-verify") { + const validAsymmetric = Object.values(AsymmetricKeyAlgorithm) as string[]; + if (!validAsymmetric.includes(e.algorithm)) { + return { + index, + message: `"algorithm" must be one of ${validAsymmetric.join(", ")} for sign-verify keys` + }; + } + if (!e.privateKey || typeof e.privateKey !== "string") { + return { + index, + message: '"privateKey" is required for sign-verify keys' + }; + } + } + return null; +}; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + projectId: string; +}; + +type ImportResult = { + succeeded: { id: string; name: string }[]; + failed: { name: string; message: string }[]; +}; + +export const CmekBulkImportModal = ({ isOpen, onOpenChange, projectId }: Props) => { + const fileInputRef = useRef(null); + const [parsedKeys, setParsedKeys] = useState(null); + const [parseError, setParseError] = useState(null); + const [validationErrors, setValidationErrors] = useState([]); + const [importResult, setImportResult] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const bulkImport = useBulkImportCmekKeys(); + + const reset = () => { + setParsedKeys(null); + setParseError(null); + setValidationErrors([]); + setImportResult(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleOpenChange = (open: boolean) => { + if (!open) reset(); + onOpenChange(open); + }; + + const handleFile = (file: File) => { + setParseError(null); + setValidationErrors([]); + const reader = new FileReader(); + reader.onload = (e) => { + try { + const raw = JSON.parse(e.target?.result as string) as unknown; + if (!Array.isArray(raw)) { + setParseError("File must contain a JSON array."); + return; + } + const errors = raw + .map((entry, i) => validateEntry(entry, i)) + .filter((err): err is ValidationError => err !== null); + setValidationErrors(errors); + setParsedKeys(raw as ParsedKey[]); + } catch { + setParseError("Could not parse file. Make sure it is valid JSON."); + } + }; + reader.readAsText(file); + }; + + const handleImport = async () => { + if (!parsedKeys) return; + try { + const { keys: imported, errors } = await bulkImport.mutateAsync({ + projectId, + keys: parsedKeys.map((k) => ({ + name: k.name, + keyUsage: k.keyType as KmsKeyUsage, + encryptionAlgorithm: k.algorithm as never, + keyMaterial: k.keyType === "sign-verify" ? (k.privateKey ?? "") : (k.keyMaterial ?? "") + })) + }); + if (errors.length === 0) { + createNotification({ + text: `Successfully imported ${imported.length} key(s)`, + type: "success" + }); + reset(); + onOpenChange(false); + } else { + setImportResult({ succeeded: imported, failed: errors }); + } + } catch { + createNotification({ text: "Failed to import keys", type: "error" }); + } + }; + + const encryptCount = parsedKeys?.filter((k) => k.keyType === "encrypt-decrypt").length ?? 0; + const signCount = parsedKeys?.filter((k) => k.keyType === "sign-verify").length ?? 0; + const errorByIndex = new Map(validationErrors.map((err) => [err.index, err.message])); + const hasErrors = validationErrors.length > 0; + + const renderContent = () => { + if (importResult) { + const total = importResult.succeeded.length + importResult.failed.length; + return ( +
+

+ + {importResult.succeeded.length} of {total} keys imported + + {importResult.failed.length > 0 && ( + — {importResult.failed.length} failed + )} +

+ + {importResult.failed.length > 0 && ( +
+

Failed imports

+
    + {importResult.failed.map((err) => ( +
  • + {err.name} + {" — "} + {err.message} +
  • + ))} +
+
+ )} + + + + + + +
+ ); + } + + if (!parsedKeys) { + return ( +
+ fileInputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }} + > + + + + + + {isDragging ? "Drop your file here" : "Upload your keys"} + + + Drag and drop your .json file here, or click to browse + + + + + {parseError && ( +

{parseError}

+ )} + +
+

Expected format

+

The file must be a JSON array. Each entry is one of:

+
{`// Encrypt/Decrypt key
+{
+  "name": "...",
+  "keyType": "encrypt-decrypt",
+  "algorithm": "...",
+  "keyMaterial": ""
+}
+
+// Sign/Verify key
+{
+  "name": "...",
+  "keyType": "sign-verify",
+  "algorithm": "...",
+  "privateKey": "",
+  "publicKey": ""
+}`}
+
+ + + + + + +
+ ); + } + + return ( +
+ + + + + # + + + Name + + + Key Type + + + Algorithm + + + + + {parsedKeys.map((key, i) => { + const errorMsg = errorByIndex.get(i); + return ( + + +
+ {i + 1} + {errorMsg ? ( + + + + + {errorMsg} + + ) : ( + + )} +
+
+ +

{String(key.name ?? "")}

+
+ +

+ {kmsKeyUsageOptions[key.keyType as KmsKeyUsage]?.label ?? key.keyType} +

+
+ +

{String(key.algorithm ?? "")}

+
+
+ ); + })} +
+
+ + {hasErrors && ( +
+ + + {validationErrors.length} validation error + {validationErrors.length > 1 ? "s" : ""} — resolve to proceed + +
+ )} + + + + + + + + +
+ ); + }; + + const getHeaderContent = (): { title: string; description: string } => { + if (importResult) { + const total = importResult.succeeded.length + importResult.failed.length; + return { + title: "Import Results", + description: `${importResult.succeeded.length} of ${total} keys imported.` + }; + } + if (parsedKeys) { + return { + title: "Review & Import Keys", + description: `${parsedKeys.length} key${parsedKeys.length !== 1 ? "s" : ""} found — ${encryptCount} encrypt/decrypt, ${signCount} sign/verify.` + }; + } + return { + title: "Import Keys", + description: + "Upload a JSON file exported from Infisical KMS to import keys into this project." + }; + }; + + const header = getHeaderContent(); + + return ( + + + + {header.title} + {header.description} + + + { + const file = e.target.files?.[0]; + if (file) handleFile(file); + }} + /> + + {renderContent()} + + + ); +}; diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index 4f3e1454edf..410fee9c75d 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -7,6 +7,7 @@ import { faDownload, faEdit, faEllipsis, + faFileImport, faFileSignature, faLock, faLockOpen, @@ -34,6 +35,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, + UnstableButtonGroup, UnstableCard, UnstableCardAction, UnstableCardContent, @@ -84,6 +86,7 @@ import { } from "@app/hooks/api/cmeks/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { CmekBulkImportModal } from "./CmekBulkImportModal"; import { CmekDecryptModal } from "./CmekDecryptModal"; import { CmekEncryptModal } from "./CmekEncryptModal"; import { CmekExportKeyModal } from "./CmekExportKeyModal"; @@ -159,7 +162,8 @@ export const CmekTable = () => { "decryptData", "signData", "verifyData", - "exportKey" + "exportKey", + "importKeys" ] as const); const handleSort = () => { @@ -300,18 +304,57 @@ export const CmekTable = () => { Manage keys and perform cryptographic operations. - - {(isAllowed) => ( - - )} - + + + {(isAllowed) => ( + + + + + Access Denied + + )} + + + + + + + + + + {(isAllowed) => ( + + + handlePopUpOpen("importKeys")} + isDisabled={!isAllowed} + > + + Import Keys + + + Access Restricted + + )} + + + + @@ -638,6 +681,11 @@ export const CmekTable = () => { onOpenChange={(isOpen) => handlePopUpToggle("exportKey", isOpen)} cmek={popUp.exportKey.data as TCmek} /> + handlePopUpToggle("importKeys", isOpen)} + projectId={projectId} + /> ); }; From 74c405d2afadf3b03e907c15ad1c217ed92ac86b Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Fri, 17 Apr 2026 23:33:06 -0300 Subject: [PATCH 7/8] feat: enhance audit log and validation for KMS bulk import - Added a new field for failed key names in the audit log metadata to track import failures. - Updated the audit log creation logic to include failed key names during bulk import. - Improved error handling in the CmekBulkImportModal to provide user feedback for file read failures and key count limits. --- .../ee/services/audit-log/audit-log-types.ts | 1 + backend/src/server/routes/v1/cmek-router.ts | 23 +++++++++---------- .../components/CmekBulkImportModal.tsx | 15 +++++++++++- 3 files changed, 26 insertions(+), 13 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 775e9fb7707..0b023c3ad20 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -3662,6 +3662,7 @@ interface CmekBulkImportKeysEvent { type: EventType.CMEK_BULK_IMPORT_KEYS; metadata: { keyNames: string[]; + failedKeyNames: string[]; projectId: string; }; } diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index 2f0062ba369..0399c825f8f 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -589,19 +589,18 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { permission ); - if (importedKeys.length > 0) { - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId, - event: { - type: EventType.CMEK_BULK_IMPORT_KEYS, - metadata: { - keyNames: importedKeys.map((k) => k.name), - projectId - } + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_BULK_IMPORT_KEYS, + metadata: { + keyNames: importedKeys.map((k) => k.name), + failedKeyNames: errors.map((e) => e.name), + projectId } - }); - } + } + }); return { keys: importedKeys, errors }; } diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx index 2e3610d5e65..3b540627d0f 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx @@ -55,7 +55,7 @@ const validateEntry = (entry: unknown, index: number): ValidationError | null => const e = entry as Record; if (!e.name || typeof e.name !== "string") { - return { index, message: `Entry ${index + 1}: "name" is required` }; + return { index, message: '"name" is required' }; } if (e.keyType !== "encrypt-decrypt" && e.keyType !== "sign-verify") { return { @@ -136,6 +136,13 @@ export const CmekBulkImportModal = ({ isOpen, onOpenChange, projectId }: Props) setParseError(null); setValidationErrors([]); const reader = new FileReader(); + const handleReadFailure = () => { + setParsedKeys(null); + setValidationErrors([]); + setParseError("Failed to read file. Please try again."); + }; + reader.onerror = handleReadFailure; + reader.onabort = handleReadFailure; reader.onload = (e) => { try { const raw = JSON.parse(e.target?.result as string) as unknown; @@ -143,6 +150,12 @@ export const CmekBulkImportModal = ({ isOpen, onOpenChange, projectId }: Props) setParseError("File must contain a JSON array."); return; } + if (raw.length > 100) { + setParseError( + `File contains ${raw.length} keys. A maximum of 100 keys can be imported at once.` + ); + return; + } const errors = raw .map((entry, i) => validateEntry(entry, i)) .filter((err): err is ValidationError => err !== null); From bd5873c0f73f946504c9c8ab605b14c79780a6cb Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Wed, 22 Apr 2026 12:40:25 -0300 Subject: [PATCH 8/8] updated 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 = {