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 aae54811158..f2b56ec2d67 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_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_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", @@ -3658,6 +3659,13 @@ interface CmekGetPrivateKeyEvent { }; } +interface CmekBulkGetPrivateKeysEvent { + type: EventType.CMEK_BULK_EXPORT_PRIVATE_KEYS; + metadata: { + keys: { keyId: string; name: string }[]; + }; +} + interface GetExternalGroupOrgRoleMappingsEvent { type: EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS; metadata?: Record; // not needed, based off orgId @@ -6371,6 +6379,7 @@ export type Event = | CmekListSigningAlgorithmsEvent | CmekGetPublicKeyEvent | CmekGetPrivateKeyEvent + | CmekBulkGetPrivateKeysEvent | GetExternalGroupOrgRoleMappingsEvent | UpdateExternalGroupOrgRoleMappingsEvent | GetProjectTemplatesEvent diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 036afc7ed79..3657fbb2784 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..0a6ded8679e 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -518,6 +518,60 @@ 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_EXPORT_PRIVATE_KEYS, + metadata: { + keys: keys.map((k) => ({ keyId: k.keyId, name: 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..0a176ebf212 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, 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"; import { + TCmekBulkGetPrivateKeysDTO, TCmekDecryptDTO, TCmekEncryptDTO, TCmekGetPrivateKeyDTO, @@ -318,6 +319,79 @@ 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 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 !== uniqueKeyIds.length) { + const foundIds = new Set(keys.map((k) => k.id)); + const missingIds = uniqueKeyIds.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) + 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: keys.map((k) => k.id) }); + + const materialByKmsId = new Map(bulkMaterials.map((m) => [m.kmsId, m])); + const asymmetricAlgorithms = new Set(Object.values(AsymmetricKeyAlgorithm)); + + const result = keys.map((key) => { + const materialEntry = materialByKmsId.get(key.id); + + 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 + ).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 }; + }; + const cmekSign = async ({ keyId, data, signingAlgorithm, isDigest }: TCmekSignDTO, actor: OrgServiceActor) => { const key = await kmsDAL.findCmekById(keyId); @@ -432,6 +506,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..132c62bfbb1 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) @@ -136,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) @@ -180,6 +239,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 +261,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 3e859c0b3a6..28ddbdad7bd 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -45,6 +45,7 @@ import { TEncryptWithKmsDataKeyDTO, TEncryptWithKmsDTO, TGenerateKMSDTO, + TGetBulkKeyMaterialDTO, TGetKeyMaterialDTO, TGetPublicKeyDTO, TImportKeyMaterialDTO, @@ -381,6 +382,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 @@ -1095,6 +1114,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/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 257e10ceb80..11d4b62261d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -3264,7 +3264,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/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..35ff6a01402 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: KmsKeyUsage; + algorithm: AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm; + 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..ecee0beb7a5 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -1,7 +1,5 @@ +import { useEffect, useState } from "react"; import { - faArrowDown, - faArrowUp, - faArrowUpRightFromSquare, faCancel, faCheck, faCheckCircle, @@ -10,41 +8,55 @@ import { faEdit, 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, InfoIcon, 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, + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Checkbox, + DocumentationLinkBadge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - EmptyState, + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, IconButton, - Input, + InputGroup, + InputGroupAddon, + InputGroupInput, Pagination, - Spinner, + Skeleton, Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TBadgeProps, Tooltip, - Tr -} from "@app/components/v2"; -import { Badge, TBadgeProps } from "@app/components/v3"; + TooltipContent, + TooltipTrigger +} from "@app/components/v3"; import { ProjectPermissionActions, ProjectPermissionCmekActions, @@ -59,7 +71,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 +91,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 +140,19 @@ export const CmekTable = () => { }); const { keys = [], totalCount = 0 } = data ?? {}; - useResetPageHelper({ - totalCount, - offset, - setPage - }); + useResetPageHelper({ totalCount, offset, setPage }); + + const [selectedKeyIds, setSelectedKeyIds] = useState([]); + + 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: "", @@ -158,55 +176,70 @@ 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 }); + if (!isDisabled) { + setSelectedKeyIds((prev) => prev.filter((id) => id !== keyId)); + } createNotification({ text: `Key successfully ${isDisabled ? "enabled" : "disabled"}`, type: "success" }); }; + 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 + }); + + 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 +253,415 @@ export const CmekTable = () => { animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > -
-
-

Keys

- - +
0 && "h-16" + )} + > +
+
{selectedKeyIds.length} Selected
+ + {(isAllowed) => ( - + + + + + + + + {isAllowed + ? "Export all selected keys as a JSON file" + : "You don't have permission to export keys"} + + )}
- 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); + }} + placeholder="Search keys by name or ID..." + /> + + {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) => !selectableKeys.find((k) => k.id === id)) + ); + } else { + setSelectedKeyIds((prev) => { + const merged = [ + ...new Set([...prev, ...selectableKeys.map((k) => k.id)]) + ]; + return merged.slice(0, 100); + }); + } + }} + /> + + + 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 - -
-
+ return ( + setCopyCipherText("")} + > + + {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] ); - })()} - -
- 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 - -
+ }} + /> + )} +
+ +
+ {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={setPage} 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} + onDeleted={(deletedKeyId) => + setSelectedKeyIds((prev) => prev.filter((id) => id !== deletedKeyId)) + } + /> + 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/DeleteCmekModal.tsx b/frontend/src/pages/kms/OverviewPage/components/DeleteCmekModal.tsx index 62389d1ba7c..a67546a94c4 100644 --- a/frontend/src/pages/kms/OverviewPage/components/DeleteCmekModal.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/DeleteCmekModal.tsx @@ -6,9 +6,10 @@ type Props = { cmek: TCmek; isOpen: boolean; onOpenChange: (isOpen: boolean) => 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); }; 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..d6a4eb1819e --- /dev/null +++ b/frontend/src/pages/kms/OverviewPage/components/jsonExport.ts @@ -0,0 +1,46 @@ +import FileSaver from "file-saver"; + +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;" + }); + FileSaver.saveAs(blob, filename); +};