diff --git a/.infisicalignore b/.infisicalignore index a88bdccbd..4ccf734b6 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -22,3 +22,5 @@ frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredent frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:28 frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:65 frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx:generic-api-key:26 +docs/documentation/platform/kms/overview.mdx:generic-api-key:281 +docs/documentation/platform/kms/overview.mdx:generic-api-key:344 diff --git a/backend/src/db/migrations/20250409161555_add-dynamic-secret-to-resource-metadata.ts b/backend/src/db/migrations/20250409161555_add-dynamic-secret-to-resource-metadata.ts new file mode 100644 index 000000000..46df5cf80 --- /dev/null +++ b/backend/src/db/migrations/20250409161555_add-dynamic-secret-to-resource-metadata.ts @@ -0,0 +1,20 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.ResourceMetadata, "dynamicSecretId"))) { + await knex.schema.alterTable(TableName.ResourceMetadata, (tb) => { + tb.uuid("dynamicSecretId"); + tb.foreign("dynamicSecretId").references("id").inTable(TableName.DynamicSecret).onDelete("CASCADE"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.ResourceMetadata, "dynamicSecretId")) { + await knex.schema.alterTable(TableName.ResourceMetadata, (tb) => { + tb.dropColumn("dynamicSecretId"); + }); + } +} diff --git a/backend/src/db/migrations/20250415010421_increase-certificate-altnames-character-limit.ts b/backend/src/db/migrations/20250415010421_increase-certificate-altnames-character-limit.ts new file mode 100644 index 000000000..5703351a8 --- /dev/null +++ b/backend/src/db/migrations/20250415010421_increase-certificate-altnames-character-limit.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.string("altNames", 4096).alter(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.string("altNames").alter(); // Defaults to varchar(255) + }); +} diff --git a/backend/src/db/migrations/20250415020304_increase-kmip-certificate-altnames-character-limit.ts b/backend/src/db/migrations/20250415020304_increase-kmip-certificate-altnames-character-limit.ts new file mode 100644 index 000000000..e412d612a --- /dev/null +++ b/backend/src/db/migrations/20250415020304_increase-kmip-certificate-altnames-character-limit.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.KmipOrgServerCertificates, (t) => { + t.string("altNames", 4096).alter(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.KmipOrgServerCertificates, (t) => { + t.string("altNames").alter(); // Defaults to varchar(255) + }); +} diff --git a/backend/src/db/schemas/resource-metadata.ts b/backend/src/db/schemas/resource-metadata.ts index f496b29db..442de66b6 100644 --- a/backend/src/db/schemas/resource-metadata.ts +++ b/backend/src/db/schemas/resource-metadata.ts @@ -16,7 +16,8 @@ export const ResourceMetadataSchema = z.object({ identityId: z.string().uuid().nullable().optional(), secretId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + dynamicSecretId: z.string().uuid().nullable().optional() }); export type TResourceMetadata = z.infer; diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index b28d4b18d..fdaaf5932 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -11,6 +11,7 @@ import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; +import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema"; export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => { server.route({ @@ -48,7 +49,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => .nullable(), path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1), - name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name) + name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name), + metadata: ResourceMetadataSchema.optional() }), response: { 200: z.object({ @@ -143,7 +145,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); }) .nullable(), - newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional() + newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional(), + metadata: ResourceMetadataSchema.optional() }) }), response: { @@ -238,6 +241,7 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => name: req.params.name, ...req.query }); + return { dynamicSecret: dynamicSecretCfg }; } }); diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index 8feee1830..88f2d90f1 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -78,10 +78,6 @@ export const dynamicSecretLeaseServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionDynamicSecretActions.Lease, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) - ); const plan = await licenseService.getPlan(actorOrgId); if (!plan?.dynamicSecret) { @@ -102,6 +98,15 @@ export const dynamicSecretLeaseServiceFactory = ({ message: `Dynamic secret with name '${name}' in folder with path '${path}' not found` }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionDynamicSecretActions.Lease, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: environmentSlug, + secretPath: path, + metadata: dynamicSecretCfg.metadata + }) + ); + const totalLeasesTaken = await dynamicSecretLeaseDAL.countLeasesForDynamicSecret(dynamicSecretCfg.id); if (totalLeasesTaken >= appCfg.MAX_LEASE_LIMIT) throw new BadRequestError({ message: `Max lease limit reached. Limit: ${appCfg.MAX_LEASE_LIMIT}` }); @@ -159,10 +164,6 @@ export const dynamicSecretLeaseServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionDynamicSecretActions.Lease, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) - ); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -187,7 +188,25 @@ export const dynamicSecretLeaseServiceFactory = ({ throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` }); } - const dynamicSecretCfg = dynamicSecretLease.dynamicSecret; + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ + id: dynamicSecretLease.dynamicSecretId, + folderId: folder.id + }); + + if (!dynamicSecretCfg) + throw new NotFoundError({ + message: `Dynamic secret with ID '${dynamicSecretLease.dynamicSecretId}' not found` + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionDynamicSecretActions.Lease, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: environmentSlug, + secretPath: path, + metadata: dynamicSecretCfg.metadata + }) + ); + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; const decryptedStoredInput = JSON.parse( secretManagerDecryptor({ cipherTextBlob: Buffer.from(dynamicSecretCfg.encryptedInput) }).toString() @@ -239,10 +258,6 @@ export const dynamicSecretLeaseServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionDynamicSecretActions.Lease, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) - ); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -259,7 +274,25 @@ export const dynamicSecretLeaseServiceFactory = ({ if (!dynamicSecretLease || dynamicSecretLease.dynamicSecret.folderId !== folder.id) throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` }); - const dynamicSecretCfg = dynamicSecretLease.dynamicSecret; + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ + id: dynamicSecretLease.dynamicSecretId, + folderId: folder.id + }); + + if (!dynamicSecretCfg) + throw new NotFoundError({ + message: `Dynamic secret with ID '${dynamicSecretLease.dynamicSecretId}' not found` + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionDynamicSecretActions.Lease, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: environmentSlug, + secretPath: path, + metadata: dynamicSecretCfg.metadata + }) + ); + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; const decryptedStoredInput = JSON.parse( secretManagerDecryptor({ cipherTextBlob: Buffer.from(dynamicSecretCfg.encryptedInput) }).toString() @@ -309,10 +342,6 @@ export const dynamicSecretLeaseServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionDynamicSecretActions.Lease, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) - ); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); if (!folder) @@ -326,6 +355,15 @@ export const dynamicSecretLeaseServiceFactory = ({ message: `Dynamic secret with name '${name}' in folder with path '${path}' not found` }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionDynamicSecretActions.Lease, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: environmentSlug, + secretPath: path, + metadata: dynamicSecretCfg.metadata + }) + ); + const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); return dynamicSecretLeases; }; @@ -352,10 +390,6 @@ export const dynamicSecretLeaseServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionDynamicSecretActions.Lease, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) - ); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); if (!folder) throw new NotFoundError({ message: `Folder with path '${path}' not found` }); @@ -364,6 +398,25 @@ export const dynamicSecretLeaseServiceFactory = ({ if (!dynamicSecretLease) throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` }); + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ + id: dynamicSecretLease.dynamicSecretId, + folderId: folder.id + }); + + if (!dynamicSecretCfg) + throw new NotFoundError({ + message: `Dynamic secret with ID '${dynamicSecretLease.dynamicSecretId}' not found` + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionDynamicSecretActions.Lease, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: environmentSlug, + secretPath: path, + metadata: dynamicSecretCfg.metadata + }) + ); + return dynamicSecretLease; }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts index e47d9102d..d7f78c3b1 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts @@ -1,9 +1,17 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TDynamicSecrets } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { + buildFindFilter, + ormify, + prependTableNameToFindFilter, + selectAllTableCols, + sqlNestRelationships, + TFindFilter, + TFindOpt +} from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; @@ -12,6 +20,86 @@ export type TDynamicSecretDALFactory = ReturnType { const orm = ormify(db, TableName.DynamicSecret); + const findOne = async (filter: TFindFilter, tx?: Knex) => { + const query = (tx || db.replicaNode())(TableName.DynamicSecret) + .leftJoin( + TableName.ResourceMetadata, + `${TableName.ResourceMetadata}.dynamicSecretId`, + `${TableName.DynamicSecret}.id` + ) + .select(selectAllTableCols(TableName.DynamicSecret)) + .select( + db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + ) + .where(prependTableNameToFindFilter(TableName.DynamicSecret, filter)); + + const docs = sqlNestRelationships({ + data: await query, + key: "id", + parentMapper: (el) => el, + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return docs[0]; + }; + + const findWithMetadata = async ( + filter: TFindFilter, + { offset, limit, sort, tx }: TFindOpt = {} + ) => { + const query = (tx || db.replicaNode())(TableName.DynamicSecret) + .leftJoin( + TableName.ResourceMetadata, + `${TableName.ResourceMetadata}.dynamicSecretId`, + `${TableName.DynamicSecret}.id` + ) + .select(selectAllTableCols(TableName.DynamicSecret)) + .select( + db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(filter)); + + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + + const docs = sqlNestRelationships({ + data: await query, + key: "id", + parentMapper: (el) => el, + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return docs; + }; + // find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination) const listDynamicSecretsByFolderIds = async ( { @@ -39,18 +127,27 @@ export const dynamicSecretDALFactory = (db: TDbClient) => { void bd.whereILike(`${TableName.DynamicSecret}.name`, `%${search}%`); } }) + .leftJoin( + TableName.ResourceMetadata, + `${TableName.ResourceMetadata}.dynamicSecretId`, + `${TableName.DynamicSecret}.id` + ) .leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.DynamicSecret}.folderId`) .leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .select( selectAllTableCols(TableName.DynamicSecret), db.ref("slug").withSchema(TableName.Environment).as("environment"), - db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.DynamicSecret}."name" ${orderDirection}) as rank`) + db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.DynamicSecret}."name" ${orderDirection}) as rank`), + db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") ) .orderBy(`${TableName.DynamicSecret}.${orderBy}`, orderDirection); + let queryWithLimit; if (limit) { const rankOffset = offset + 1; - return await (tx || db) + queryWithLimit = (tx || db.replicaNode()) .with("w", query) .select("*") .from[number]>("w") @@ -58,7 +155,22 @@ export const dynamicSecretDALFactory = (db: TDbClient) => { .andWhere("w.rank", "<", rankOffset + limit); } - const dynamicSecrets = await query; + const dynamicSecrets = sqlNestRelationships({ + data: await (queryWithLimit || query), + key: "id", + parentMapper: (el) => el, + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); return dynamicSecrets; } catch (error) { @@ -66,5 +178,5 @@ export const dynamicSecretDALFactory = (db: TDbClient) => { } }; - return { ...orm, listDynamicSecretsByFolderIds }; + return { ...orm, listDynamicSecretsByFolderIds, findOne, findWithMetadata }; }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts index 4bd384bcf..05d492240 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -42,7 +42,7 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) = inputHostIps.push(...resolvedIps); } - if (!isGateway && !appCfg.DYNAMIC_SECRET_ALLOW_INTERNAL_IP) { + if (!isGateway && !(appCfg.DYNAMIC_SECRET_ALLOW_INTERNAL_IP || appCfg.ALLOW_INTERNAL_IP_CONNECTIONS)) { const isInternalIp = inputHostIps.some((el) => isPrivateIp(el)); if (isInternalIp) throw new BadRequestError({ message: "Invalid db host" }); } diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 25ea21024..44c18b001 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -12,6 +12,7 @@ import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TResourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal"; @@ -46,6 +47,7 @@ type TDynamicSecretServiceFactoryDep = { permissionService: Pick; kmsService: Pick; projectGatewayDAL: Pick; + resourceMetadataDAL: Pick; }; export type TDynamicSecretServiceFactory = ReturnType; @@ -60,7 +62,8 @@ export const dynamicSecretServiceFactory = ({ dynamicSecretQueueService, projectDAL, kmsService, - projectGatewayDAL + projectGatewayDAL, + resourceMetadataDAL }: TDynamicSecretServiceFactoryDep) => { const create = async ({ path, @@ -73,7 +76,8 @@ export const dynamicSecretServiceFactory = ({ projectSlug, actorOrgId, defaultTTL, - actorAuthMethod + actorAuthMethod, + metadata }: TCreateDynamicSecretDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -87,9 +91,10 @@ export const dynamicSecretServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); + ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionDynamicSecretActions.CreateRootCredential, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) + subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path, metadata }) ); const plan = await licenseService.getPlan(actorOrgId); @@ -131,16 +136,36 @@ export const dynamicSecretServiceFactory = ({ projectId }); - const dynamicSecretCfg = await dynamicSecretDAL.create({ - type: provider.type, - version: 1, - encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(inputs)) }).cipherTextBlob, - maxTTL, - defaultTTL, - folderId: folder.id, - name, - projectGatewayId: selectedGatewayId + const dynamicSecretCfg = await dynamicSecretDAL.transaction(async (tx) => { + const cfg = await dynamicSecretDAL.create( + { + type: provider.type, + version: 1, + encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(inputs)) }).cipherTextBlob, + maxTTL, + defaultTTL, + folderId: folder.id, + name, + projectGatewayId: selectedGatewayId + }, + tx + ); + + if (metadata) { + await resourceMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + key, + value, + dynamicSecretId: cfg.id, + orgId: actorOrgId + })), + tx + ); + } + + return cfg; }); + return dynamicSecretCfg; }; @@ -156,7 +181,8 @@ export const dynamicSecretServiceFactory = ({ actorId, newName, actorOrgId, - actorAuthMethod + actorAuthMethod, + metadata }: TUpdateDynamicSecretDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -171,10 +197,6 @@ export const dynamicSecretServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionDynamicSecretActions.EditRootCredential, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) - ); const plan = await licenseService.getPlan(actorOrgId); if (!plan?.dynamicSecret) { @@ -193,6 +215,27 @@ export const dynamicSecretServiceFactory = ({ message: `Dynamic secret with name '${name}' in folder '${folder.path}' not found` }); } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionDynamicSecretActions.EditRootCredential, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: environmentSlug, + secretPath: path, + metadata: dynamicSecretCfg.metadata + }) + ); + + if (metadata) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionDynamicSecretActions.EditRootCredential, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: environmentSlug, + secretPath: path, + metadata + }) + ); + } + if (newName) { const existingDynamicSecret = await dynamicSecretDAL.findOne({ name: newName, folderId: folder.id }); if (existingDynamicSecret) @@ -231,14 +274,41 @@ export const dynamicSecretServiceFactory = ({ const isConnected = await selectedProvider.validateConnection(newInput); if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); - const updatedDynamicCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, { - encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(updatedInput)) }).cipherTextBlob, - maxTTL, - defaultTTL, - name: newName ?? name, - status: null, - statusDetails: null, - projectGatewayId: selectedGatewayId + const updatedDynamicCfg = await dynamicSecretDAL.transaction(async (tx) => { + const cfg = await dynamicSecretDAL.updateById( + dynamicSecretCfg.id, + { + encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(updatedInput)) }) + .cipherTextBlob, + maxTTL, + defaultTTL, + name: newName ?? name, + status: null, + projectGatewayId: selectedGatewayId + }, + tx + ); + + if (metadata) { + await resourceMetadataDAL.delete( + { + dynamicSecretId: cfg.id + }, + tx + ); + + await resourceMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + key, + value, + dynamicSecretId: cfg.id, + orgId: actorOrgId + })), + tx + ); + } + + return cfg; }); return updatedDynamicCfg; @@ -268,10 +338,6 @@ export const dynamicSecretServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionDynamicSecretActions.DeleteRootCredential, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) - ); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); if (!folder) @@ -282,6 +348,15 @@ export const dynamicSecretServiceFactory = ({ throw new NotFoundError({ message: `Dynamic secret with name '${name}' in folder '${folder.path}' not found` }); } + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionDynamicSecretActions.DeleteRootCredential, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: environmentSlug, + secretPath: path, + metadata: dynamicSecretCfg.metadata + }) + ); + const leases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); // when not forced we check with the external system to first remove the things // we introduce a forced concept because consider the external lease got deleted by some other external like a human or another system @@ -329,14 +404,6 @@ export const dynamicSecretServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionDynamicSecretActions.ReadRootCredential, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) - ); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionDynamicSecretActions.EditRootCredential, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) - ); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); if (!folder) @@ -346,6 +413,25 @@ export const dynamicSecretServiceFactory = ({ if (!dynamicSecretCfg) { throw new NotFoundError({ message: `Dynamic secret with name '${name} in folder '${path}' not found` }); } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionDynamicSecretActions.ReadRootCredential, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: environmentSlug, + secretPath: path, + metadata: dynamicSecretCfg.metadata + }) + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionDynamicSecretActions.EditRootCredential, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: environmentSlug, + secretPath: path, + metadata: dynamicSecretCfg.metadata + }) + ); + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId @@ -356,6 +442,7 @@ export const dynamicSecretServiceFactory = ({ ) as object; const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput)) as object; + return { ...dynamicSecretCfg, inputs: providerInputs }; }; @@ -426,7 +513,7 @@ export const dynamicSecretServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionDynamicSecretActions.ReadRootCredential, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) + ProjectPermissionSub.DynamicSecrets ); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); @@ -473,16 +560,12 @@ export const dynamicSecretServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionDynamicSecretActions.ReadRootCredential, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) - ); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); if (!folder) throw new NotFoundError({ message: `Folder with path '${path}' in environment '${environmentSlug}' not found` }); - const dynamicSecretCfg = await dynamicSecretDAL.find( + const dynamicSecretCfg = await dynamicSecretDAL.findWithMetadata( { folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined }, { limit, @@ -490,7 +573,17 @@ export const dynamicSecretServiceFactory = ({ sort: orderBy ? [[orderBy, orderDirection]] : undefined } ); - return dynamicSecretCfg; + + return dynamicSecretCfg.filter((dynamicSecret) => { + return permission.can( + ProjectPermissionDynamicSecretActions.ReadRootCredential, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: environmentSlug, + secretPath: path, + metadata: dynamicSecret.metadata + }) + ); + }); }; const listDynamicSecretsByFolderIds = async ( @@ -542,24 +635,14 @@ export const dynamicSecretServiceFactory = ({ isInternal, ...params }: TListDynamicSecretsMultiEnvDTO) => { - if (!isInternal) { - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.SecretManager - }); - - // verify user has access to each env in request - environmentSlugs.forEach((environmentSlug) => - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionDynamicSecretActions.ReadRootCredential, - subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) - ) - ); - } + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path); if (!folders.length) @@ -572,7 +655,16 @@ export const dynamicSecretServiceFactory = ({ ...params }); - return dynamicSecretCfg; + return dynamicSecretCfg.filter((dynamicSecret) => { + return permission.can( + ProjectPermissionDynamicSecretActions.ReadRootCredential, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: dynamicSecret.environment, + secretPath: path, + metadata: dynamicSecret.metadata + }) + ); + }); }; const fetchAzureEntraIdUsers = async ({ diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts index 957d884c8..58fdc2143 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { OrderByDirection, TProjectPermission } from "@app/lib/types"; +import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { DynamicSecretProviderSchema } from "./providers/models"; @@ -20,6 +21,7 @@ export type TCreateDynamicSecretDTO = { environmentSlug: string; name: string; projectSlug: string; + metadata?: ResourceMetadataDTO; } & Omit; export type TUpdateDynamicSecretDTO = { @@ -31,6 +33,7 @@ export type TUpdateDynamicSecretDTO = { environmentSlug: string; inputs?: TProvider["inputs"]; projectSlug: string; + metadata?: ResourceMetadataDTO; } & Omit; export type TDeleteDynamicSecretDTO = { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index c14bbb518..b5cfadbeb 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -155,6 +155,10 @@ export type SecretFolderSubjectFields = { export type DynamicSecretSubjectFields = { environment: string; secretPath: string; + metadata?: { + key: string; + value: string; + }[]; }; export type SecretImportSubjectFields = { @@ -284,6 +288,42 @@ const SecretConditionV1Schema = z }) .partial(); +const DynamicSecretConditionV2Schema = z + .object({ + environment: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]), + secretPath: SECRET_PATH_PERMISSION_OPERATOR_SCHEMA, + metadata: z.object({ + [PermissionConditionOperators.$ELEMENTMATCH]: z + .object({ + key: z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial(), + value: z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + }) + .partial() + }) + }) + .partial(); + const SecretConditionV2Schema = z .object({ environment: z.union([ @@ -581,7 +621,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionDynamicSecretActions).describe( "Describe what action an entity can take." ), - conditions: SecretConditionV1Schema.describe( + conditions: DynamicSecretConditionV2Schema.describe( "When specified, only matching conditions will be allowed to access given resource." ).optional() }), diff --git a/backend/src/lib/casl/index.ts b/backend/src/lib/casl/index.ts index 147d12ef7..7a3c05969 100644 --- a/backend/src/lib/casl/index.ts +++ b/backend/src/lib/casl/index.ts @@ -24,5 +24,6 @@ export enum PermissionConditionOperators { $IN = "$in", $EQ = "$eq", $NEQ = "$ne", - $GLOB = "$glob" + $GLOB = "$glob", + $ELEMENTMATCH = "$elemMatch" } diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 10ab16b97..907884433 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -197,6 +197,7 @@ const envSchema = z /* ----------------------------------------------------------------------------- */ /* App Connections ----------------------------------------------------------------------------- */ + ALLOW_INTERNAL_IP_CONNECTIONS: zodStrBool.default("false"), // aws INF_APP_CONNECTION_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()), diff --git a/backend/src/lib/crypto/sign/signing.ts b/backend/src/lib/crypto/sign/signing.ts index 7dd71b5f6..66f36dc0f 100644 --- a/backend/src/lib/crypto/sign/signing.ts +++ b/backend/src/lib/crypto/sign/signing.ts @@ -118,7 +118,12 @@ export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSi } }; - const $signRsaDigest = async (digest: Buffer, privateKey: Buffer, hashAlgorithm: SupportedHashAlgorithm) => { + const $signRsaDigest = async ( + digest: Buffer, + privateKey: Buffer, + hashAlgorithm: SupportedHashAlgorithm, + signingAlgorithm: SigningAlgorithm + ) => { const tempDir = await createTemporaryDirectory("kms-rsa-sign"); const digestPath = path.join(tempDir, "digest.bin"); const sigPath = path.join(tempDir, "signature.bin"); @@ -164,12 +169,22 @@ export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSi } return signature; + } catch (err) { + logger.error(err, "KMS: Failed to sign RSA digest"); + throw new BadRequestError({ + message: `Failed to sign RSA digest with ${signingAlgorithm} due to signing error. Ensure that your digest is hashed with ${hashAlgorithm.toUpperCase()}.` + }); } finally { await cleanTemporaryDirectory(tempDir); } }; - const $signEccDigest = async (digest: Buffer, privateKey: Buffer, hashAlgorithm: SupportedHashAlgorithm) => { + const $signEccDigest = async ( + digest: Buffer, + privateKey: Buffer, + hashAlgorithm: SupportedHashAlgorithm, + signingAlgorithm: SigningAlgorithm + ) => { const tempDir = await createTemporaryDirectory("ecc-sign"); const digestPath = path.join(tempDir, "digest.bin"); const keyPath = path.join(tempDir, "key.pem"); @@ -216,6 +231,11 @@ export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSi } return signature; + } catch (err) { + logger.error(err, "KMS: Failed to sign ECC digest"); + throw new BadRequestError({ + message: `Failed to sign ECC digest with ${signingAlgorithm} due to signing error. Ensure that your digest is hashed with ${hashAlgorithm.toUpperCase()}.` + }); } finally { await cleanTemporaryDirectory(tempDir); } @@ -329,7 +349,12 @@ export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSi const signDigestFunctionsMap: Record< AsymmetricKeyAlgorithm, - (data: Buffer, privateKey: Buffer, hashAlgorithm: SupportedHashAlgorithm) => Promise + ( + data: Buffer, + privateKey: Buffer, + hashAlgorithm: SupportedHashAlgorithm, + signingAlgorithm: SigningAlgorithm + ) => Promise > = { [AsymmetricKeyAlgorithm.ECC_NIST_P256]: $signEccDigest, [AsymmetricKeyAlgorithm.RSA_4096]: $signRsaDigest @@ -360,7 +385,7 @@ export const signingService = (algorithm: AsymmetricKeyAlgorithm): TAsymmetricSi }); } - const signature = await signFunction(data, privateKey, hashAlgorithm); + const signature = await signFunction(data, privateKey, hashAlgorithm, signingAlgorithm); return signature; } diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index 6feab9036..fdf99e405 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -2,10 +2,16 @@ import dns from "node:dns/promises"; import { isIPv4 } from "net"; +import { getConfig } from "@app/lib/config/env"; + import { BadRequestError } from "../errors"; import { isPrivateIp } from "../ip/ipRange"; export const blockLocalAndPrivateIpAddresses = async (url: string) => { + const appCfg = getConfig(); + + if (appCfg.isDevelopmentMode) return; + const validUrl = new URL(url); const inputHostIps: string[] = []; if (isIPv4(validUrl.host)) { @@ -18,7 +24,8 @@ export const blockLocalAndPrivateIpAddresses = async (url: string) => { inputHostIps.push(...resolvedIps); } const isInternalIp = inputHostIps.some((el) => isPrivateIp(el)); - if (isInternalIp) throw new BadRequestError({ message: "Local IPs not allowed as URL" }); + if (isInternalIp && !appCfg.ALLOW_INTERNAL_IP_CONNECTIONS) + throw new BadRequestError({ message: "Local IPs not allowed as URL" }); }; type FQDNOptions = { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 743577d25..9d8585361 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1391,7 +1391,8 @@ export const registerRoutes = async ( permissionService, licenseService, kmsService, - projectGatewayDAL + projectGatewayDAL, + resourceMetadataDAL }); const dynamicSecretLeaseService = dynamicSecretLeaseServiceFactory({ diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 4dbd60377..da300981c 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -11,6 +11,7 @@ import { UsersSchema } from "@app/db/schemas"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema"; import { UnpackedPermissionSchema } from "./sanitizedSchema/permission"; @@ -232,7 +233,11 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ inputIV: true, inputTag: true, algorithm: true -}); +}).merge( + z.object({ + metadata: ResourceMetadataSchema.optional() + }) +); export const SanitizedAuditLogStreamSchema = z.object({ id: z.string(), diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 5d75a6d2c..5a52d4748 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -1,13 +1,9 @@ -import { ForbiddenError, subject } from "@casl/ability"; +import { ForbiddenError } from "@casl/ability"; import { z } from "zod"; -import { ActionProjectType, SecretFoldersSchema, SecretImportsSchema } from "@app/db/schemas"; +import { SecretFoldersSchema, SecretImportsSchema } from "@app/db/schemas"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; -import { - ProjectPermissionDynamicSecretActions, - ProjectPermissionSecretActions, - ProjectPermissionSub -} from "@app/ee/services/permission/project-permission"; +import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; import { DASHBOARD } from "@app/lib/api-docs"; import { BadRequestError } from "@app/lib/errors"; @@ -317,24 +313,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { totalCount: totalFolderCount ?? 0 }; - const { permission } = await server.services.permission.getProjectPermission({ - actor: req.permission.type, - actorId: req.permission.id, - projectId, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - actionProjectType: ActionProjectType.SecretManager - }); - - const allowedDynamicSecretEnvironments = // filter envs user has access to - environments.filter((environment) => - permission.can( - ProjectPermissionDynamicSecretActions.Lease, - subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath }) - ) - ); - - if (includeDynamicSecrets && allowedDynamicSecretEnvironments.length) { + if (includeDynamicSecrets) { // this is the unique count, ie duplicate secrets across envs only count as 1 totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({ actor: req.permission.type, @@ -343,7 +322,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, projectId, search, - environmentSlugs: allowedDynamicSecretEnvironments, + environmentSlugs: environments, path: secretPath, isInternal: true }); @@ -358,7 +337,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { search, orderBy, orderDirection, - environmentSlugs: allowedDynamicSecretEnvironments, + environmentSlugs: environments, path: secretPath, limit: remainingLimit, offset: adjustedOffset, diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index b55564d80..dbfa715ea 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -39,17 +39,19 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.CREATE.path), + .describe(FOLDERS.CREATE.path) + .optional(), // backward compatiability with cli directory: z .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.CREATE.directory), + .describe(FOLDERS.CREATE.directory) + .optional(), description: z.string().optional().nullable().describe(FOLDERS.CREATE.description) }), response: { @@ -60,7 +62,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.body.path || req.body.directory; + const path = req.body.path || req.body.directory || "/"; const folder = await server.services.folder.createFolder({ actorId: req.permission.id, actor: req.permission.type, @@ -120,17 +122,19 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.UPDATE.path), + .describe(FOLDERS.UPDATE.path) + .optional(), // backward compatiability with cli directory: z .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.UPDATE.directory), + .describe(FOLDERS.UPDATE.directory) + .optional(), description: z.string().optional().nullable().describe(FOLDERS.UPDATE.description) }), response: { @@ -141,7 +145,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.body.path || req.body.directory; + const path = req.body.path || req.body.directory || "/"; const { folder, old } = await server.services.folder.updateFolder({ actorId: req.permission.id, actor: req.permission.type, @@ -271,17 +275,19 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.DELETE.path), + .describe(FOLDERS.DELETE.path) + .optional(), // keep this here as cli need directory directory: z .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined .transform(removeTrailingSlash) .describe(FOLDERS.DELETE.directory) + .optional() }), response: { 200: z.object({ @@ -291,7 +297,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.body.path || req.body.directory; + const path = req.body.path || req.body.directory || "/"; const folder = await server.services.folder.deleteFolder({ actorId: req.permission.id, actor: req.permission.type, @@ -339,18 +345,18 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => path: z .string() .trim() - .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.LIST.path), + .describe(FOLDERS.LIST.path) + .optional(), // backward compatiability with cli directory: z .string() .trim() - .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.LIST.directory), + .describe(FOLDERS.LIST.directory) + .optional(), recursive: booleanSchema.default(false).describe(FOLDERS.LIST.recursive) }), response: { @@ -363,7 +369,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.query.path || req.query.directory; + const path = req.query.path || req.query.directory || "/"; const folders = await server.services.folder.getFolders({ actorId: req.permission.id, actor: req.permission.type, diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index c40d6b22b..d21003b10 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -819,16 +819,17 @@ export const secretImportServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) - ); + if ( + permission.cannot( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) + ) + ) { + return []; + } const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); - if (!folder) - throw new NotFoundError({ - message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found` - }); + if (!folder) return []; const importedBy = await secretImportDAL.getFolderIsImportedBy(secretPath, folder.envId, environment, projectId); const deepPaths: { path: string; folderId: string }[] = []; diff --git a/docs/documentation/platform/dynamic-secrets/mssql.mdx b/docs/documentation/platform/dynamic-secrets/mssql.mdx index aca42c5c4..2a279ce90 100644 --- a/docs/documentation/platform/dynamic-secrets/mssql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mssql.mdx @@ -35,6 +35,10 @@ Create a user with the required permission in your SQL instance. This user will Maximum time-to-live for a generated secret + + List of key/value metadata pairs + + Choose the service you want to generate dynamic secrets for. This must be selected as **MS SQL**. diff --git a/docs/documentation/platform/dynamic-secrets/mysql.mdx b/docs/documentation/platform/dynamic-secrets/mysql.mdx index da39c0a56..f88a88d35 100644 --- a/docs/documentation/platform/dynamic-secrets/mysql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mysql.mdx @@ -34,6 +34,10 @@ Create a user with the required permission in your SQL instance. This user will Maximum time-to-live for a generated secret + + List of key/value metadata pairs + + Choose the service you want to generate dynamic secrets for. This must be selected as **MySQL**. diff --git a/docs/documentation/platform/dynamic-secrets/oracle.mdx b/docs/documentation/platform/dynamic-secrets/oracle.mdx index e8fa86028..c7b34bec9 100644 --- a/docs/documentation/platform/dynamic-secrets/oracle.mdx +++ b/docs/documentation/platform/dynamic-secrets/oracle.mdx @@ -34,6 +34,10 @@ Create a user with the required permission in your SQL instance. This user will Maximum time-to-live for a generated secret + + List of key/value metadata pairs + + Choose the service you want to generate dynamic secrets for. This must be selected as **Oracle**. @@ -62,7 +66,7 @@ Create a user with the required permission in your SQL instance. This user will A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-oracle.png) diff --git a/docs/documentation/platform/dynamic-secrets/postgresql.mdx b/docs/documentation/platform/dynamic-secrets/postgresql.mdx index 5216d87af..feb81d6d6 100644 --- a/docs/documentation/platform/dynamic-secrets/postgresql.mdx +++ b/docs/documentation/platform/dynamic-secrets/postgresql.mdx @@ -35,6 +35,10 @@ Create a user with the required permission in your SQL instance. This user will Maximum time-to-live for a generated secret + + List of key/value metadata pairs + + Choose the service you want to generate dynamic secrets for. This must be selected as **PostgreSQL**. @@ -63,7 +67,7 @@ Create a user with the required permission in your SQL instance. This user will A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-postgresql.png) diff --git a/docs/documentation/platform/kms/overview.mdx b/docs/documentation/platform/kms/overview.mdx index 8991646cf..577373ab8 100644 --- a/docs/documentation/platform/kms/overview.mdx +++ b/docs/documentation/platform/kms/overview.mdx @@ -30,7 +30,9 @@ The typical workflow for using Infisical KMS consists of the following steps: as via API. -## Guide to Encrypting Data +## Encryption + +### Guide to Encrypting Data In the following steps, we explore how to generate a key and use it to encrypt data. @@ -44,7 +46,8 @@ In the following steps, we explore how to generate a key and use it to encrypt d Specify your key details. Here's some guidance on each field: - Name: A slug-friendly name for the key. - - Type: The encryption algorithm associated with the key (e.g. `AES-GCM-256`). + - Key Usage: The type of key to create (e.g `Encrypt/Decrypt` for encryption, and `Sign/Verify` for signing). + - Algorithm: The encryption algorithm associated with the key (e.g. `AES-GCM-256`). - Description: An optional description of what the intended usage is for the key. ![kms add key modal](/images/platform/kms/infisical-kms/kms-add-key-modal.png) @@ -137,7 +140,7 @@ In the following steps, we explore how to generate a key and use it to encrypt d -## Guide to Decrypting Data +### Guide to Decrypting Data In the following steps, we explore how to use decrypt data using an existing key in Infisical KMS. @@ -193,6 +196,164 @@ In the following steps, we explore how to use decrypt data using an existing key +## Signing + +### Guide to Signing Data + +In the following steps, we explore how to generate a key and use it to sign data. + + + + + + Navigate to Project > Key Management and tap on the **Add Key** button. + ![kms add key button](/images/platform/kms/infisical-kms/kms-add-key.png) + + Specify your key details. Here's some guidance on each field: + + - Name: A slug-friendly name for the key. + - Key Usage: The type of key to create (e.g `Encrypt/Decrypt` for encryption, and `Sign/Verify` for signing). + - Algorithm: The signing algorithm associated with the key (e.g. `RSA_4096`). + - Description: An optional description of what the intended usage is for the key. + + ![kms add key modal](/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png) + + + + Once your key is generated, open the options menu for the newly created key and select sign data. + ![kms key options](/images/platform/kms/infisical-kms/signing/sign-options.png) + + Populate the text area with your data and tap on the Sign button. + ![kms sign data](/images/platform/kms/infisical-kms/signing/sign-data-modal.png) + + Make sure to select the appropriate signing algorithm that will be used to sign the data. + Supported signing algorithms are: + + **For RSA keys:** + - `RSASSA PSS SHA 512`: Not deterministic, and includes random salt. + - `RSASSA PSS SHA 384`: Not deterministic, and includes random salt. + - `RSASSA PSS SHA 256`: Not deterministic, and includes random salt. + - `RSASSA PKCS1 V1.5 SHA 512`: Deterministic, and does not include randomness. + - `RSASSA PKCS1 V1.5 SHA 384`: Deterministic, and does not include randomness. + - `RSASSA PKCS1 V1.5 SHA 256`: Deterministic, and does not include randomness. + + **For ECC keys:** + - `ECDSA SHA 512`: Not deterministic, and includes randomness. + - `ECDSA SHA 384`: Not deterministic, and includes randomness. + - `ECDSA SHA 256`: Not deterministic, and includes randomness. + + In this example, we'll use the `RSASSA PSS SHA 512` signing algorithm. + + + If your data is already Base64 encoded make sure to toggle the respective switch on to avoid + redundant encoding. + + + Copy and store the signature of your data. + ![kms signed data](/images/platform/kms/infisical-kms/signing/copy-signature.png) + + + + + + + To sign data, make an API request to the [Sign + Data](/api-reference/endpoints/kms/signing/sign) API endpoint, + specifying the key to use. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/kms/keys//sign \ + --header 'Content-Type: application/json' \ + --data '{ + "data": "SGVsbG8sIFdvcmxkIQ==", // base64 encoded data + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512", + }' + ``` + + ### Sample response + + ```bash Response + { + "signature": "JYuiBt1Ta9pbqFIW9Ou6qzBsFhjYbMJp9k4dP87ILrO+F2MPnp85g3nOlXK1ttZmRoGWsWnLNDRn9W3rf5VtkeaixPqUW/KvY/fM3CxdMyIV3BuxlGgDksjL8X34Eqkrz4CCPo9hjB5uT+rBCOxCgZqRbOdATPipAneUapI9npseNquEeh3jPklwviBix83PJHV9PW2t03AGGUXuMY55ZaFEIMv+IrI1WYdnPVIXDyIitYsS3y+/6KRfhVeTcPNJ5Rw+FE9y1eZzDEZtTNpxOfUT3QIoXmpZlYL4HbhRuJBZ+Yx54C7uPiUIN9U69XbyXt+Kkynykw2HPaagwuCZxiqCU5sFfLnrVbc3dmZxQcX2yRrs2gmFamzBx+uVbi648H4mb7WuE5UPTBjjA11jRsBjCY0YS2T4Vgfe1RlzlPQkZgjP/bnCCGDqXa3/VZAlZX1nTI51X995bPHBQI0rq2sNDlIXenwiAy1wJSITbSI8DbUx09Cr83xCEaYAE6R6PUfog/tbIUXi0VbrYsCVkAGCK446Wb1vW6q7HR8jrjXNwmXlqN9eLbSVWqdWj7N7fieeTYSrECtUaAjxtUYTIVsH2bfT6FOEM9gMWKffOpFowVzzr3B9bNQLIhnEEwebxBw947i4OcxyVIcEUuumWxoKvcbSPxzJ8v1M3SoBBh4=", // base64 encoded signature + "keyId": "62b2c14e-58af-4199-9842-02995c63edf9", + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512", + } + ``` + + + To sign predigested data, you can pass `"isDigest": true` in the request body. This requires the data to be a base64 encoded digest of the data you wish to sign. + It's important that the digest is created using the same hashing algorithm as the signing algorithm. As an example, you would create the digest with `SHA512` if you are using the `RSASSA_PKCS1_V1_5_SHA_512` signing algorithm. + + + + + + +### Guide to Verifying Data + +In the following steps, we explore how to verify data using an existing key in Infisical KMS. + + + + + + Navigate to Project > Key Management and open the options menu for the key used to sign the data + you want to verify. + ![kms key options](/images/platform/kms/infisical-kms/signing/sign-options.png) + + + + Paste your signature and data into the text areas and tap on the Verify button. + ![kms verify data](/images/platform/kms/infisical-kms/signing/verify-data-modal.png) + + Your verification result will be displayed and can be copied for use. + ![kms verified data](/images/platform/kms/infisical-kms/signing/signature-verified.png) + + If the signature is invalid, you'll see an error message indicating that the signature is invalid, and the "Signature Status" field will be `Invalid`. + + + + + + + To verify data, make an API request to the [Verify + Data](/api-reference/endpoints/kms/signing/verify) API endpoint, + specifying the key to use. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/kms/keys//verify \ + --header 'Content-Type: application/json' \ + --data '{ + "data": "SGVsbG8sIFdvcmxkIQ==", // base64 encoded data + "signature": "JYuiBt1Ta9pbqFIW9Ou6qzBsFhjYbMJp9k4dP87ILrO+F2MPnp85g3nOlXK1ttZmRoGWsWnLNDRn9W3rf5VtkeaixPqUW/KvY/fM3CxdMyIV3BuxlGgDksjL8X34Eqkrz4CCPo9hjB5uT+rBCOxCgZqRbOdATPipAneUapI9npseNquEeh3jPklwviBix83PJHV9PW2t03AGGUXuMY55ZaFEIMv+IrI1WYdnPVIXDyIitYsS3y+/6KRfhVeTcPNJ5Rw+FE9y1eZzDEZtTNpxOfUT3QIoXmpZlYL4HbhRuJBZ+Yx54C7uPiUIN9U69XbyXt+Kkynykw2HPaagwuCZxiqCU5sFfLnrVbc3dmZxQcX2yRrs2gmFamzBx+uVbi648H4mb7WuE5UPTBjjA11jRsBjCY0YS2T4Vgfe1RlzlPQkZgjP/bnCCGDqXa3/VZAlZX1nTI51X995bPHBQI0rq2sNDlIXenwiAy1wJSITbSI8DbUx09Cr83xCEaYAE6R6PUfog/tbIUXi0VbrYsCVkAGCK446Wb1vW6q7HR8jrjXNwmXlqN9eLbSVWqdWj7N7fieeTYSrECtUaAjxtUYTIVsH2bfT6FOEM9gMWKffOpFowVzzr3B9bNQLIhnEEwebxBw947i4OcxyVIcEUuumWxoKvcbSPxzJ8v1M3SoBBh4=", // base64 encoded signature + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512" + }' + ``` + + ### Sample response + + ```bash Response + { + "signatureValid": true, + "keyId": "62b2c14e-58af-4199-9842-02995c63edf9", + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512" + } + ``` + + To verify predigested data, you can pass `"isDigest": true` in the request body. This requires the data to be a base64 encoded digest of the data you wish to verify. + It's important that the digest is created using the same hashing algorithm as the signing algorithm. As an example, you would create the digest with `SHA512` if you are using the `RSASSA_PKCS1_V1_5_SHA_512` signing algorithm. + + + + + + ## FAQ @@ -205,8 +366,76 @@ In the following steps, we explore how to use decrypt data using an existing key external sources. - Currently, Infisical only supports `AES-128-GCM` and `AES-256-GCM` for - encryption operations. We anticipate supporting more algorithms and - cryptographic operations in the coming months. + Currently Infisical supports 4 different key algorithms with different purposes: + + - `RSA_4096`: For signing and verifying data. + - `ECC_NIST_P256`: For signing and verifying data. + + - `AES-256-GCM`: For encryption and decryption operations. + - `AES-128-GCM`: For encryption and decryption operations. + + We anticipate to further expand our supported algorithms and support cryptographic operations in the future. + + + To sign and verify a digest using the Infisical KMS, you can use the `Sign` and `Verify` endpoints respectively. + You will need to pass `"isDigest": true` in the request body to indicate that you are signing or verifying a digest. + The data you are signing or verifying will need to be a base64 encoded digest of the data you wish to sign or verify. + It's important that the digest is created using the same hashing algorithm as the signing algorithm. As an example, you would create the digest with `SHA512` if you are using the `RSASSA_PKCS1_V1_5_SHA_512` signing algorithm. + + To create a SHA512 digest of your data, you can use the following command with OpenSSL: + ```bash + echo -n "Hello, World" | openssl dgst -sha512 -binary | openssl base64 + ``` + + ### Sample request for signing a digest + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/kms/keys//sign \ + --header 'Content-Type: application/json' \ + --data '{ + "data": , + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512", + "isDigest": true + }' + ``` + + ### Sample response for signing a digest + + ```bash Response + { + "signature": , + "keyId": , + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512" + } + ``` + + ### Sample request for verifying a digest + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/kms/keys//verify \ + --header 'Content-Type: application/json' \ + --data '{ + "data": , + "signature": , + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512", + "isDigest": true + }' + ``` + + ### Sample response for verifying a digest + + ```bash Response + { + "signatureValid": true, + "keyId": , + "signingAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512" + } + ``` + + + Please note that `RSA PSS` signing algorithms are not supported for digest signing and verification. Please use `RSA PKCS1 V1.5` signing algorithms for digest signing and verification, or `ECDSA` if you're using an ECC key. + diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png deleted file mode 100644 index 053873a9c..000000000 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png and /dev/null differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png index 7f296a441..89994c55e 100644 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-oracle.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-oracle.png new file mode 100644 index 000000000..0e3f64e7c Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-oracle.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-postgresql.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-postgresql.png new file mode 100644 index 000000000..39fd4243b Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-postgresql.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png b/docs/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png new file mode 100644 index 000000000..97d7ca246 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/copy-signature.png b/docs/images/platform/kms/infisical-kms/signing/copy-signature.png new file mode 100644 index 000000000..2644b358b Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/copy-signature.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/sign-data-modal.png b/docs/images/platform/kms/infisical-kms/signing/sign-data-modal.png new file mode 100644 index 000000000..da8a01438 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/sign-data-modal.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/sign-options.png b/docs/images/platform/kms/infisical-kms/signing/sign-options.png new file mode 100644 index 000000000..7129c1d5b Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/sign-options.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/signature-verified.png b/docs/images/platform/kms/infisical-kms/signing/signature-verified.png new file mode 100644 index 000000000..70b7856b6 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/signature-verified.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/verify-data-modal.png b/docs/images/platform/kms/infisical-kms/signing/verify-data-modal.png new file mode 100644 index 000000000..6d3683c9f Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/signing/verify-data-modal.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx index cb8a5bce0..ab2f4638f 100644 --- a/docs/integrations/app-connections/aws.mdx +++ b/docs/integrations/app-connections/aws.mdx @@ -56,7 +56,15 @@ Infisical supports two methods for connecting to AWS. 2. Select **AWS Account** as the **Trusted Entity Type**. 3. Choose **Another AWS Account** and enter **381492033652** (Infisical AWS Account ID). This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. - 4. Optionally, enable **Require external ID** and enter your **Organization ID** to further enhance security. + 4. (Recommended) Enable "Require external ID" and input your **Organization ID** to strengthen security and mitigate the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). + + + When configuring an IAM Role that Infisical will assume, it’s highly recommended to enable the **"Require external ID"** option and specify your **Organization ID**. + + This precaution helps protect your AWS account against the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html), a potential security vulnerability where Infisical could be tricked into performing actions on your behalf by an unauthorized actor. + + Always enable "Require external ID" and use your Organization ID when setting up the IAM Role. + diff --git a/docs/integrations/app-connections/mssql.mdx b/docs/integrations/app-connections/mssql.mdx index 45082103b..7e940804d 100644 --- a/docs/integrations/app-connections/mssql.mdx +++ b/docs/integrations/app-connections/mssql.mdx @@ -51,6 +51,10 @@ Infisical supports connecting to Microsoft SQL Server using database principals. - `username` - The username of the login created in the steps above - `password` - The password of the login created in the steps above - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) + + + If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`. + diff --git a/docs/integrations/app-connections/postgres.mdx b/docs/integrations/app-connections/postgres.mdx index 523fc35a8..860e9ee3c 100644 --- a/docs/integrations/app-connections/postgres.mdx +++ b/docs/integrations/app-connections/postgres.mdx @@ -41,6 +41,10 @@ Infisical supports connecting to PostgreSQL using a database role. - `username` - The role name of the login created in the steps above - `password` - The role password of the login created in the steps above - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) + + + If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`. + diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx index e04d11c6d..b5792611d 100644 --- a/docs/sdks/languages/go.mdx +++ b/docs/sdks/languages/go.mdx @@ -284,7 +284,7 @@ if err != nil { } ``` -## Working With Secrets +## Secrets ### List Secrets @@ -591,7 +591,7 @@ Create multiple secrets in Infisical. -## Working With Folders +## Folders ### @@ -748,3 +748,353 @@ deletedFolder, err := client.Folders().Delete(infisical.DeleteFolderOptions{ + +## KMS + +### Create Key + +`client.Kms().Keys().Create(options)` + +Create a new key in Infisical. + +```go + newKey, err := client.Kms().Keys().Create(infisical.KmsCreateKeyOptions{ + KeyUsage: "|", + Description: "", + Name: "", + EncryptionAlgorithm: "|||", + ProjectId: "", + }) +``` + +#### Parameters + + + + + The usage of the key. Valid options are `sign-verify` or `encrypt-decrypt`. + The usage dictates what the key can be used for. + + + The description of the key. + + + The name of the key. + + + The encryption algorithm of the key. + + Valid options for Signing/Verifying keys are: + - `rsa-4096` + - `ecc-nist-p256` + + Valid options for Encryption/Decryption keys are: + - `aes-256-gcm` + - `aes-128-gcm` + + + The ID of the project where the key will be created. + + + + +#### Return (object) + + + + The ID of the key that was created. + + + The name of the key that was created. + + + The description of the key that was created. + + + Whether or not the key is disabled. + + + The ID of the organization that the key belongs to. + + + The ID of the project that the key belongs to. + + + The intended usage of the key that was created. + + + The encryption algorithm of the key that was created. + + + The version of the key that was created. + + + + +### Delete Key + +`client.Kms().Keys().Delete(options)` + +Delete a key in Infisical. + +```go +deletedKey, err = client.Kms().Keys().Delete(infisical.KmsDeleteKeyOptions{ + KeyId: "", + }) +``` + +#### Parameters + + + + + The ID of the key to delete. + + + + +#### Return (object) + + + + The ID of the key that was deleted + + + The name of the key that was deleted. + + + The description of the key that was deleted. + + + Whether or not the key is disabled. + + + The ID of the organization that the key belonged to. + + + The ID of the project that the key belonged to. + + + The intended usage of the key that was deleted. + + + The encryption algorithm of the key that was deleted. + + + The version of the key that was deleted. + + + + +### Signing Data + +`client.Kms().Signing().Sign(options)` +Sign data in Infisical. + +```go +res, err := client.Kms().Signing().SignData(infisical.KmsSignDataOptions{ + KeyId: "", + Data: "", // Must be a base64 encoded string. + SigningAlgorithm: "", // The signing algorithm that will be used to sign the data. +}) +``` + +#### Parameters + + + + + The ID of the key to sign the data with. + + + The data to sign. Must be a base64 encoded string. + + + Whether the data is already digested or not. + + + The signing algorithm to use. You must use a signing algorithm that matches the key usage. + + + If you are unsure about which signing algorithms are available for your key, you can use the `client.Kms().Signing().ListSigningAlgorithms()` method. It will return an array of signing algorithms that are available for your key. + + + Valid options for `RSA 4096` keys are: + - `RSASSA_PSS_SHA_512` + - `RSASSA_PSS_SHA_384` + - `RSASSA_PSS_SHA_256` + - `RSASSA_PKCS1_V1_5_SHA_512` + - `RSASSA_PKCS1_V1_5_SHA_384` + - `RSASSA_PKCS1_V1_5_SHA_256` + + Valid options for `ECC NIST P256` keys are: + - `ECDSA_SHA_512` + - `ECDSA_SHA_384` + - `ECDSA_SHA_256` + + + + +#### Return ([]byte) + + The signature of the data that was signed. + + +### Verifying Data + +`client.Kms().Signing().Verify(options)` +Verify data in Infisical. + +```go +res, err := client.Kms().Signing().Verify(infisical.KmsVerifyDataOptions{ + KeyId: "", + Data: "", // Must be a base64 encoded string. + SigningAlgorithm: "", // The signing algorithm that was used to sign the data. +}) +``` + +#### Parameters + + + + + The ID of the key to verify the data with. + + + The data to verify. Must be a base64 encoded string. + + + Whether the data is already digested or not. + + + The signing algorithm that was used to sign the data. + + + + +#### Return (object) + + + + Whether or not the data is valid. + + + The ID of the key that was used to verify the data. + + + The signing algorithm that was used to verify the data. + + + + +### List Signing Algorithms + +`client.Kms().Signing().ListSigningAlgorithms(options)` +List signing algorithms in Infisical. + +```go +res, err := client.Kms().Signing().ListSigningAlgorithms(infisical.KmsListSigningAlgorithmsOptions{ + KeyId: "", +}) +``` + +#### Parameters + + + + + The ID of the key to list signing algorithms for. + + + + +#### Return ([]string) + + The signing algorithms that are available for the key. + + +### Get Public Key + + This method is only available for keys with key usage `sign-verify`. If you attempt to use this method on a key that is intended for encryption/decryption, it will return an error. + + +`client.Kms().Signing().GetPublicKey(options)` +Get the public key in Infisical. + +```go +publicKey, err := client.Kms().Signing().GetPublicKey(infisical.KmsGetPublicKeyOptions{ + KeyId: "", +}) +``` + +#### Parameters + + + + + The ID of the key to get the public key for. + + + + +#### Return (string) + + The public key for the key. + + +### Encrypt Data + +`client.Kms().Encryption().Encrypt(options)` +Encrypt data with a key in Infisical KMS. + +```go +res, err := client.Kms().EncryptData(infisical.KmsEncryptDataOptions{ + KeyId: "", + Plaintext: "", +}) +``` + +#### Parameters + + + + + The ID of the key to encrypt the data with. + + + + +#### Return (string) + + The encrypted data. + + +### Decrypt Data + +`client.Kms().DecryptData(options)` +Decrypt data with a key in Infisical KMS. + +```go +res, err := client.Kms().DecryptData(infisical.KmsDecryptDataOptions{ + KeyId: "", + Ciphertext: "", +}) +``` + +#### Parameters + + + + + The ID of the key to decrypt the data with. + + + The encrypted data to decrypt. + + + + +#### Return (string) + + The decrypted data. + diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 8eda21edd..103c6400e 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -34,6 +34,10 @@ Used to configure platform-specific security and operational settings this to `false`. + + Determines whether App Connections and Dynamic Secrets are permitted to connect with internal/private IP addresses. + + ## CORS Cross-Origin Resource Sharing (CORS) is a security feature that allows web applications running on one domain to access resources from another domain. diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 27fd0273c..a327913d9 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -100,7 +100,8 @@ export enum PermissionConditionOperators { $REGEX = "$regex", $EQ = "$eq", $NEQ = "$ne", - $GLOB = "$glob" + $GLOB = "$glob", + $ELEMENTMATCH = "$elemMatch" } export type IdentityManagementSubjectFields = { @@ -113,7 +114,8 @@ export const formatedConditionsOperatorNames: { [K in PermissionConditionOperato [PermissionConditionOperators.$ALL]: "contains all", [PermissionConditionOperators.$NEQ]: "not equal to", [PermissionConditionOperators.$GLOB]: "matches glob pattern", - [PermissionConditionOperators.$REGEX]: "matches regex pattern" + [PermissionConditionOperators.$REGEX]: "matches regex pattern", + [PermissionConditionOperators.$ELEMENTMATCH]: "element matches" }; export type TPermissionConditionOperators = { @@ -123,12 +125,24 @@ export type TPermissionConditionOperators = { [PermissionConditionOperators.$NEQ]: string; [PermissionConditionOperators.$REGEX]: string; [PermissionConditionOperators.$GLOB]: string; + [PermissionConditionOperators.$ELEMENTMATCH]: Record< + string, + Partial + >; }; export type TPermissionCondition = Record< string, | string - | { $in: string[]; $all: string[]; $regex: string; $eq: string; $ne: string; $glob: string } + | { + $in: string[]; + $all: string[]; + $regex: string; + $eq: string; + $ne: string; + $glob: string; + $elemMatch: Partial; + } >; export enum ProjectPermissionSub { @@ -182,6 +196,7 @@ export type SecretFolderSubjectFields = { export type DynamicSecretSubjectFields = { environment: string; secretPath: string; + metadata?: (string | { key: string; value: string })[]; }; export type SecretImportSubjectFields = { diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 7d8c6920a..1aedf264f 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -13,6 +13,7 @@ export type TDynamicSecret = { status?: DynamicSecretStatus; statusDetails?: string; maxTTL: string; + metadata?: { key: string; value: string }[]; }; export enum DynamicSecretProviders { @@ -261,6 +262,7 @@ export type TDynamicSecretProvider = digits?: number; }; }; + export type TCreateDynamicSecretDTO = { projectSlug: string; provider: TDynamicSecretProvider; @@ -269,6 +271,7 @@ export type TCreateDynamicSecretDTO = { path: string; environmentSlug: string; name: string; + metadata?: { key: string; value: string }[]; }; export type TUpdateDynamicSecretDTO = { @@ -278,6 +281,7 @@ export type TUpdateDynamicSecretDTO = { environmentSlug: string; data: { newName?: string; + metadata?: { key: string; value: string }[]; defaultTTL?: string; maxTTL?: string | null; inputs?: unknown; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/DynamicSecretPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/DynamicSecretPermissionConditions.tsx new file mode 100644 index 000000000..0183a5d93 --- /dev/null +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/DynamicSecretPermissionConditions.tsx @@ -0,0 +1,189 @@ +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Button, + FormControl, + IconButton, + Input, + Select, + SelectItem, + Tooltip +} from "@app/components/v2"; +import { + PermissionConditionOperators, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; + +import { + getConditionOperatorHelperInfo, + renderOperatorSelectItems +} from "./PermissionConditionHelpers"; +import { TFormSchema } from "./ProjectRoleModifySection.utils"; + +type Props = { + position?: number; + isDisabled?: boolean; +}; + +export const DynamicSecretPermissionConditions = ({ position = 0, isDisabled }: Props) => { + const { + control, + watch, + setValue, + formState: { errors } + } = useFormContext(); + const items = useFieldArray({ + control, + name: `permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions` + }); + + const conditionErrorMessage = + errors?.permissions?.[ProjectPermissionSub.DynamicSecrets]?.[position]?.conditions?.message || + errors?.permissions?.[ProjectPermissionSub.DynamicSecrets]?.[position]?.conditions?.root + ?.message; + + return ( +
+

Conditions

+

+ Conditions determine when a policy will be applied (always if no conditions are present). +

+

+ All conditions must evaluate to true for the policy to take effect. +

+
+ {items.fields.map((el, index) => { + const condition = watch( + `permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}` + ) as { + lhs: string; + rhs: string; + operator: string; + }; + return ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+ + + +
+
+
+ ( + + + + )} + /> +
+
+ items.remove(index)} + > + + +
+
+ ); + })} +
+ {conditionErrorMessage && ( +
+ + {conditionErrorMessage} +
+ )} +
+ +
+
+ ); +}; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index c02b1ccc0..57d617527 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -285,11 +285,59 @@ const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition } else { Object.keys(condition).forEach((conditionOperator) => { const rhs = condition[conditionOperator as PermissionConditionOperators]; - formConditions.push({ - operator: conditionOperator, - lhs: type, - rhs: typeof rhs === "string" ? rhs : rhs.join(",") - }); + if (Array.isArray(rhs) || typeof rhs === "string") { + formConditions.push({ + operator: conditionOperator, + lhs: type, + rhs: typeof rhs === "string" ? rhs : rhs.join(",") + }); + } else if ( + conditionOperator === PermissionConditionOperators.$ELEMENTMATCH && + type === "metadata" + ) { + const deepKeyCondition = rhs.key; + if (deepKeyCondition) { + if (typeof deepKeyCondition === "string") { + formConditions.push({ + operator: PermissionConditionOperators.$EQ, + lhs: "metadataKey", + rhs: deepKeyCondition + }); + } else { + Object.keys(deepKeyCondition).forEach((keyOperator) => { + const deepRhs = deepKeyCondition?.[keyOperator as PermissionConditionOperators]; + if (deepRhs && (Array.isArray(deepRhs) || typeof deepRhs === "string")) { + formConditions.push({ + operator: keyOperator, + lhs: "metadataKey", + rhs: typeof deepRhs === "string" ? deepRhs : deepRhs.join(",") + }); + } + }); + } + } + const deepValueCondition = rhs.value; + if (deepValueCondition) { + if (typeof deepValueCondition === "string") { + formConditions.push({ + operator: PermissionConditionOperators.$EQ, + lhs: "metadataValue", + rhs: deepValueCondition + }); + } else { + Object.keys(deepValueCondition).forEach((keyOperator) => { + const deepRhs = deepValueCondition?.[keyOperator as PermissionConditionOperators]; + if (deepRhs && (Array.isArray(deepRhs) || typeof deepRhs === "string")) { + formConditions.push({ + operator: keyOperator, + lhs: "metadataValue", + rhs: typeof deepRhs === "string" ? deepRhs : deepRhs.join(",") + }); + } + }); + } + } + } }); } }); @@ -636,7 +684,45 @@ const convertFormOperatorToCaslCondition = ( conditions: { lhs: string; rhs: string; operator: string }[] ) => { const caslCondition: Record> = {}; + + const metadataKeyCondition = conditions.find((condition) => condition.lhs === "metadataKey"); + const metadataValueCondition = conditions.find((condition) => condition.lhs === "metadataValue"); + + if (metadataKeyCondition || metadataValueCondition) { + caslCondition.metadata = { + [PermissionConditionOperators.$ELEMENTMATCH]: {} + }; + + if (metadataKeyCondition) { + const operator = metadataKeyCondition.operator as PermissionConditionOperators; + caslCondition.metadata[PermissionConditionOperators.$ELEMENTMATCH]!.key = { + [metadataKeyCondition.operator]: [ + PermissionConditionOperators.$IN, + PermissionConditionOperators.$ALL + ].includes(operator) + ? metadataKeyCondition.rhs.split(",") + : metadataKeyCondition.rhs + }; + } + + if (metadataValueCondition) { + const operator = metadataValueCondition.operator as PermissionConditionOperators; + caslCondition.metadata[PermissionConditionOperators.$ELEMENTMATCH]!.value = { + [metadataValueCondition.operator]: [ + PermissionConditionOperators.$IN, + PermissionConditionOperators.$ALL + ].includes(operator) + ? metadataValueCondition.rhs.split(",") + : metadataValueCondition.rhs + }; + } + } + conditions.forEach((el) => { + // these are special fields and handled above + if (el.lhs === "metadataKey" || el.lhs === "metadataValue") { + return; + } if (!caslCondition[el.lhs]) caslCondition[el.lhs] = {}; if ( el.operator === PermissionConditionOperators.$IN || @@ -647,7 +733,9 @@ const convertFormOperatorToCaslCondition = ( caslCondition[el.lhs][ el.operator as Exclude< PermissionConditionOperators, - PermissionConditionOperators.$ALL | PermissionConditionOperators.$IN + | PermissionConditionOperators.$ALL + | PermissionConditionOperators.$IN + | PermissionConditionOperators.$ELEMENTMATCH > ] = el.rhs; } diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx index 5b2dc65f2..2f46bd065 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx @@ -21,6 +21,7 @@ import { evaluatePermissionsAbility } from "@app/helpers/permissions"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; import { ProjectType } from "@app/hooks/api/workspace/types"; +import { DynamicSecretPermissionConditions } from "./DynamicSecretPermissionConditions"; import { GeneralPermissionConditions } from "./GeneralPermissionConditions"; import { GeneralPermissionPolicies } from "./GeneralPermissionPolicies"; import { IdentityManagementPermissionConditions } from "./IdentityManagementPermissionConditions"; @@ -48,6 +49,9 @@ export const renderConditionalComponents = ( if (subject === ProjectPermissionSub.Secrets) return ; + if (subject === ProjectPermissionSub.DynamicSecrets) + return ; + if (isConditionalSubjects(subject)) { if (subject === ProjectPermissionSub.Identity) { return ; diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 5fe31a7b3..f974a7f1a 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -207,7 +207,8 @@ export const OverviewPage = () => { ProjectPermissionDynamicSecretActions.CreateRootCredential, subject(ProjectPermissionSub.DynamicSecrets, { environment: env.slug, - secretPath + secretPath, + metadata: ["*"] }) ) ); diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index e6a5461e4..63398d152 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -138,7 +138,7 @@ const Page = () => { const canReadDynamicSecret = permission.can( ProjectPermissionDynamicSecretActions.ReadRootCredential, - subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath }) + subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath, metadata: ["*"] }) ); const canReadSecretRotations = permission.can( @@ -532,7 +532,7 @@ const Page = () => { importedBy={importedBy} /> )} - {canReadSecret && } + {noAccessSecretCount > 0 && } {!canReadSecret && !canReadDynamicSecret && !canReadSecretImports && diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx index c62f15d95..4ac4ac804 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -864,7 +864,8 @@ export const ActionBar = ({ environment, secretPath, secretName: "*", - secretTags: ["*"] + secretTags: ["*"], + metadata: ["*"] })} > {(isAllowed) => ( diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx index 732713a1a..136ca2773 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx @@ -25,6 +25,8 @@ import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api"; import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types"; import { WorkspaceEnv } from "@app/hooks/api/types"; +import { MetadataForm } from "../../DynamicSecretListView/MetadataForm"; + const passwordRequirementsSchema = z .object({ length: z.number().min(1).max(250), @@ -82,8 +84,16 @@ const formSchema = z.object({ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); }), name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"), - environment: z.object({ name: z.string(), slug: z.string() }) + environment: z.object({ name: z.string(), slug: z.string() }), + metadata: z + .object({ + key: z.string().trim().min(1), + value: z.string().trim().default("") + }) + .array() + .optional() }); + type TForm = z.infer; type Props = { @@ -192,7 +202,8 @@ export const SqlDatabaseInputForm = ({ maxTTL, provider, defaultTTL, - environment + environment, + metadata }: TForm) => { // wait till previous request is finished if (createDynamicSecret.isPending) return; @@ -205,7 +216,8 @@ export const SqlDatabaseInputForm = ({ path: secretPath, defaultTTL, projectSlug, - environmentSlug: environment.slug + environmentSlug: environment.slug, + metadata }); onCompleted(); } catch { @@ -283,46 +295,47 @@ export const SqlDatabaseInputForm = ({ /> -
- ( - - - - )} - /> -
+
Configuration
+
+ ( + + + + )} + /> +
Service
{ const { handlePopUpOpen, popUp, handlePopUpClose, handlePopUpToggle } = usePopUp([ "deleteSecret", @@ -140,7 +143,11 @@ export const DynamicSecretLease = ({
@@ -159,7 +166,11 @@ export const DynamicSecretLease = ({ @@ -181,7 +192,8 @@ export const DynamicSecretLease = ({ I={ProjectPermissionDynamicSecretActions.Lease} a={subject(ProjectPermissionSub.DynamicSecrets, { environment, - secretPath + secretPath, + metadata: dynamicSecret.metadata })} renderTooltip allowedLabel="Force Delete. This action will remove the secret from internal storage, but it will remain in external systems." @@ -215,7 +227,8 @@ export const DynamicSecretLease = ({ I={ProjectPermissionDynamicSecretActions.Lease} a={subject(ProjectPermissionSub.DynamicSecrets, { environment, - secretPath + secretPath, + metadata: dynamicSecret.metadata })} > {(isAllowed) => ( diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretListView.tsx index bfb341a40..d652d49ad 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretListView.tsx @@ -144,7 +144,11 @@ export const DynamicSecretListView = ({
@@ -186,7 +190,11 @@ export const DynamicSecretListView = ({
@@ -208,7 +216,11 @@ export const DynamicSecretListView = ({ @@ -236,6 +248,7 @@ export const DynamicSecretListView = ({ className="max-w-3xl" > handlePopUpOpen("createDynamicSecretLease", secret)} onClose={() => handlePopUpClose("dynamicSecretLeases")} projectSlug={projectSlug} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx index 554c46a52..7c15db140 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx @@ -23,6 +23,8 @@ import { useWorkspace } from "@app/context"; import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api"; import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; +import { MetadataForm } from "../MetadataForm"; + const passwordRequirementsSchema = z .object({ length: z.number().min(1).max(250), @@ -85,6 +87,13 @@ const formSchema = z.object({ newName: z .string() .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .optional(), + metadata: z + .object({ + key: z.string().trim().min(1), + value: z.string().trim().default("") + }) + .array() .optional() }); type TForm = z.infer; @@ -126,6 +135,7 @@ export const EditDynamicSecretSqlProviderForm = ({ defaultTTL: dynamicSecret.defaultTTL, maxTTL: dynamicSecret.maxTTL, newName: dynamicSecret.name, + metadata: dynamicSecret.metadata, inputs: { ...(dynamicSecret.inputs as TForm["inputs"]), passwordRequirements: @@ -147,7 +157,13 @@ export const EditDynamicSecretSqlProviderForm = ({ const isGatewayInActive = projectGateways?.findIndex((el) => el.projectGatewayId === selectedProjectGatewayId) === -1; - const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => { + const handleUpdateDynamicSecret = async ({ + inputs, + maxTTL, + defaultTTL, + newName, + metadata + }: TForm) => { // wait till previous request is finished if (updateDynamicSecret.isPending) return; try { @@ -163,7 +179,8 @@ export const EditDynamicSecretSqlProviderForm = ({ ...inputs, projectGatewayId: isGatewayInActive ? null : inputs.projectGatewayId }, - newName: newName === dynamicSecret.name ? undefined : newName + newName: newName === dynamicSecret.name ? undefined : newName, + metadata } }); onClose(); @@ -229,46 +246,50 @@ export const EditDynamicSecretSqlProviderForm = ({ />
-
- ( - - - - )} - /> -
+
Configuration
+
+ ( + + + + )} + /> +
}) => { + const metadataFormFields = useFieldArray({ + control, + name: "metadata" + }); + + return ( + +
+ {metadataFormFields.fields.map(({ id: metadataFieldId }, i) => ( +
+
+ {i === 0 && Key} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ metadataFormFields.remove(i)} + > + + +
+ ))} +
0 ? "pt-2" : ""}`}> + metadataFormFields.append({ key: "", value: "" })} + > + + +
+
+
+ ); +};