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/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-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/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 8bf6f7390..2a87cf7cf 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/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/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/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: "" })} + > + + +
+
+
+ ); +};