Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/src/ee/services/audit-log/audit-log-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_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",
Expand Down Expand Up @@ -3648,6 +3649,14 @@ interface CmekGetPrivateKeyEvent {
};
}

interface CmekBulkGetPrivateKeysEvent {
type: EventType.CMEK_BULK_EXPORT_PRIVATE_KEYS;
metadata: {
keyIds: string[];
Comment thread
victorvhs017 marked this conversation as resolved.
Outdated
keyNames: string[];
};
}

interface GetExternalGroupOrgRoleMappingsEvent {
type: EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS;
metadata?: Record<string, never>; // not needed, based off orgId
Expand Down Expand Up @@ -6288,6 +6297,7 @@ export type Event =
| CmekListSigningAlgorithmsEvent
| CmekGetPublicKeyEvent
| CmekGetPrivateKeyEvent
| CmekBulkGetPrivateKeysEvent
| GetExternalGroupOrgRoleMappingsEvent
| UpdateExternalGroupOrgRoleMappingsEvent
| GetProjectTemplatesEvent
Expand Down
58 changes: 29 additions & 29 deletions backend/src/ee/services/license/license-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = {
Expand Down
4 changes: 4 additions & 0 deletions backend/src/lib/api-docs/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down
55 changes: 55 additions & 0 deletions backend/src/server/routes/v1/cmek-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_EXPORT_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",
Expand Down
78 changes: 76 additions & 2 deletions backend/src/services/cmek/cmek-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
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,
Expand Down Expand Up @@ -318,6 +319,78 @@
};
};

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" });

if (keys.length !== keyIds.length) {
const foundIds = new Set(keys.map((k) => k.id));
const missingIds = keyIds.filter((id) => !foundIds.has(id));
Comment thread
victorvhs017 marked this conversation as resolved.
Outdated
throw new NotFoundError({ message: `Keys not found for IDs: ${missingIds.join(", ")}` });
}

const projectIds = new Set<string>();
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(
Comment thread
victorvhs017 marked this conversation as resolved.
ProjectPermissionCmekActions.ExportPrivateKey,
ProjectPermissionSub.Cmek
);

const bulkMaterials = await kmsService.getBulkKeyMaterial({ kmsIds: keyIds });

const result = await Promise.all(
keys.map(async (key) => {
Comment thread
victorvhs017 marked this conversation as resolved.
Outdated
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) {

Check failure on line 371 in backend/src/services/cmek/cmek-service.ts

View check run for this annotation

Claude / Claude Code Review

N+1 DB queries for public key derivation in bulkGetPrivateKeys

In `bulkGetPrivateKeys()`, for each asymmetric key `kmsService.getPublicKey({ kmsId: key.id })` is called inside `Promise.all`, and each call issues a separate `kmsDAL.findByIdWithAssociatedKms(kmsId)` DB round-trip — up to 100 extra queries for a full bulk export. Since `getBulkKeyMaterial()` already returns the decrypted private key in `materialEntry.keyMaterial`, the public key can be derived directly via `signingService(key.encryptionAlgorithm as AsymmetricKeyAlgorithm).getPublicKeyFromPriva
Comment thread
victorvhs017 marked this conversation as resolved.
Outdated
const pubKeyBuffer = await kmsService.getPublicKey({ kmsId: key.id });
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"),
...(publicKey ? { publicKey } : {})
};
})
);

return { keys: result, projectId };
};

const cmekSign = async ({ keyId, data, signingAlgorithm, isDigest }: TCmekSignDTO, actor: OrgServiceActor) => {
const key = await kmsDAL.findCmekById(keyId);

Expand Down Expand Up @@ -432,6 +505,7 @@
cmekVerify,
listSigningAlgorithms,
getPublicKey,
getPrivateKey
getPrivateKey,
bulkGetPrivateKeys
};
};
4 changes: 4 additions & 0 deletions backend/src/services/cmek/cmek-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export type TCmekGetPrivateKeyDTO = {
keyId: string;
};

export type TCmekBulkGetPrivateKeysDTO = {
keyIds: string[];
};

export type TCmekSignDTO = {
keyId: string;
data: string;
Expand Down
75 changes: 74 additions & 1 deletion backend/src/services/kms/kms-key-dal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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({
Expand All @@ -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
};
};
Loading
Loading