From 07898414a337df8b86cb31a595777adf96fbf62f Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 11 Apr 2025 00:20:02 +0800 Subject: [PATCH] feat: add metadata based permissions for dynamic secret --- ...add-dynamic-secret-to-resource-metadata.ts | 20 ++ backend/src/db/schemas/resource-metadata.ts | 3 +- .../src/ee/routes/v1/dynamic-secret-router.ts | 8 +- .../dynamic-secret/dynamic-secret-dal.ts | 39 +++- .../dynamic-secret/dynamic-secret-service.ts | 91 +++++++-- .../dynamic-secret/dynamic-secret-types.ts | 3 + .../services/permission/project-permission.ts | 38 +++- backend/src/lib/casl/index.ts | 3 +- backend/src/server/routes/index.ts | 3 +- backend/src/server/routes/sanitizedSchemas.ts | 7 +- .../context/ProjectPermissionContext/types.ts | 20 +- frontend/src/hooks/api/dynamicSecret/types.ts | 4 + .../DynamicSecretPermissionConditions.tsx | 188 ++++++++++++++++++ .../ProjectRoleModifySection.utils.tsx | 100 +++++++++- .../components/RolePermissionsSection.tsx | 4 + .../SqlDatabaseInputForm.tsx | 91 +++++---- .../EditDynamicSecretSqlProviderForm.tsx | 101 ++++++---- .../DynamicSecretListView/MetadataForm.tsx | 76 +++++++ 18 files changed, 681 insertions(+), 118 deletions(-) create mode 100644 backend/src/db/migrations/20250409161555_add-dynamic-secret-to-resource-metadata.ts create mode 100644 frontend/src/pages/project/RoleDetailsBySlugPage/components/DynamicSecretPermissionConditions.tsx create mode 100644 frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/MetadataForm.tsx 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/dynamic-secret-dal.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts index e47d9102d..9efac9fcc 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts @@ -3,7 +3,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; @@ -12,6 +12,41 @@ export type TDynamicSecretDALFactory = ReturnType { const orm = ormify(db, TableName.DynamicSecret); + const findOneWithMetadata = async (filter: { name?: string; folderId?: string }, 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(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]; + }; + // find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination) const listDynamicSecretsByFolderIds = async ( { @@ -66,5 +101,5 @@ export const dynamicSecretDALFactory = (db: TDbClient) => { } }; - return { ...orm, listDynamicSecretsByFolderIds }; + return { ...orm, listDynamicSecretsByFolderIds, findOneWithMetadata }; }; 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..b3e93805c 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` }); @@ -131,16 +135,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 +180,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` }); @@ -231,14 +256,36 @@ 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 + }); + + 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; @@ -342,10 +389,11 @@ export const dynamicSecretServiceFactory = ({ if (!folder) throw new NotFoundError({ message: `Folder with path '${path}' in environment '${environmentSlug}' not found` }); - const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + const dynamicSecretCfg = await dynamicSecretDAL.findOneWithMetadata({ name, folderId: folder.id }); if (!dynamicSecretCfg) { throw new NotFoundError({ message: `Dynamic secret with name '${name} in folder '${path}' not found` }); } + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId @@ -356,6 +404,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 }; }; 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 4d3fff12a..dbb74de65 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -265,6 +265,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([ @@ -547,7 +583,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 21988e12d..74295e4d4 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1364,7 +1364,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/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 656d9466b..0eba72143 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -90,7 +90,8 @@ export enum PermissionConditionOperators { $REGEX = "$regex", $EQ = "$eq", $NEQ = "$ne", - $GLOB = "$glob" + $GLOB = "$glob", + $ELEMENTMATCH = "$elemMatch" } export type IdentityManagementSubjectFields = { @@ -103,7 +104,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 = { @@ -113,12 +115,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 { 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..d2f3e3fab --- /dev/null +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/DynamicSecretPermissionConditions.tsx @@ -0,0 +1,188 @@ +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?.["dynamic-secrets"]?.[position]?.conditions?.message || + errors?.permissions?.["dynamic-secrets"]?.[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.dynamic-secrets.${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 2d77aac83..5abcf6eec 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -266,11 +266,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(",") + }); + } + }); + } + } + } }); } }); @@ -585,7 +633,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 || @@ -596,7 +682,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 d44927edf..f3822bd3a 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx @@ -20,6 +20,7 @@ import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; import { evaluatePermissionsAbility } from "@app/helpers/permissions"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; +import { DynamicSecretPermissionConditions } from "./DynamicSecretPermissionConditions"; import { GeneralPermissionConditions } from "./GeneralPermissionConditions"; import { GeneralPermissionPolicies } from "./GeneralPermissionPolicies"; import { IdentityManagementPermissionConditions } from "./IdentityManagementPermissionConditions"; @@ -46,6 +47,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/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
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: "" })} + > + + +
+
+
+ ); +};