From 3f654e115daf1a9e79d1b557c05c1c4019e22807 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Wed, 18 Jun 2025 00:17:39 +0400 Subject: [PATCH 1/6] feat(secret-syncs): better permissioning --- .../services/permission/project-permission.ts | 42 +++- .../secret-sync/secret-sync-service.ts | 126 +++++++++--- .../forms/SecretSyncSourceFields.tsx | 32 ++- .../context/ProjectPermissionContext/types.ts | 13 +- .../ProjectRoleModifySection.utils.tsx | 19 +- .../components/RolePermissionsSection.tsx | 5 + .../SecretSyncPermissionConditions.tsx | 186 ++++++++++++++++++ 7 files changed, 381 insertions(+), 42 deletions(-) create mode 100644 frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretSyncPermissionConditions.tsx diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index f61c4b1a4..f11f703fa 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -211,6 +211,11 @@ export type SecretFolderSubjectFields = { secretPath: string; }; +export type SecretSyncSubjectFields = { + environment: string; + secretPath: string; +}; + export type DynamicSecretSubjectFields = { environment: string; secretPath: string; @@ -267,6 +272,10 @@ export type ProjectPermissionSet = | (ForcedSubject & DynamicSecretSubjectFields) ) ] + | [ + ProjectPermissionSecretSyncActions, + ProjectPermissionSub.SecretSyncs | (ForcedSubject & SecretSyncSubjectFields) + ] | [ ProjectPermissionActions, ( @@ -323,7 +332,6 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] - | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] | [ProjectPermissionKmipActions, ProjectPermissionSub.Kmip] | [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] @@ -412,6 +420,23 @@ const DynamicSecretConditionV2Schema = z }) .partial(); +const SecretSyncConditionV2Schema = z + .object({ + environment: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + secretPath: SECRET_PATH_PERMISSION_OPERATOR_SCHEMA + }) + .partial(); + const SecretImportConditionSchema = z .object({ environment: z.union([ @@ -671,12 +696,6 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), - z.object({ - subject: z.literal(ProjectPermissionSub.SecretSyncs).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretSyncActions).describe( - "Describe what action an entity can take." - ) - }), z.object({ subject: z.literal(ProjectPermissionSub.Kmip).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionKmipActions).describe( @@ -836,6 +855,15 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretSyncs).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretSyncActions).describe( + "Describe what action an entity can take." + ), + conditions: SecretSyncConditionV2Schema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), ...GeneralPermissionSchema ]); diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index b64620827..1bb27c05a 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -217,13 +217,17 @@ export const secretSyncServiceFactory = ({ ForbiddenError.from(projectPermission).throwUnlessCan( ProjectPermissionSecretSyncActions.Create, - ProjectPermissionSub.SecretSyncs + subject(ProjectPermissionSub.SecretSyncs, { environment, secretPath }) ); - throwIfMissingSecretReadValueOrDescribePermission(projectPermission, ProjectPermissionSecretActions.ReadValue, { - environment, - secretPath - }); + throwIfMissingSecretReadValueOrDescribePermission( + projectPermission, + ProjectPermissionSecretActions.DescribeSecret, + { + environment, + secretPath + } + ); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); @@ -286,10 +290,38 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretSyncActions.Edit, - ProjectPermissionSub.SecretSyncs - ); + // we always check the permission against the existing environment / secret path + // if no secret path / environment is present on the secret sync, we need to check without conditions + if (secretSync.environment?.slug && secretSync.folder?.path) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Edit, + subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Edit, + ProjectPermissionSub.SecretSyncs + ); + } + + // if the user is updating the secret path or environment, we need to check the permission against the new values + if (secretPath || environment) { + const environmentToCheck = environment || secretSync.environment?.slug || ""; + const secretPathToCheck = secretPath || secretSync.folder?.path || ""; + + if (environmentToCheck && secretPathToCheck) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Edit, + subject(ProjectPermissionSub.SecretSyncs, { + environment: environmentToCheck, + secretPath: secretPathToCheck + }) + ); + } + } if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ @@ -315,7 +347,7 @@ export const secretSyncServiceFactory = ({ if (!updatedEnvironment || !updatedSecretPath) throw new BadRequestError({ message: "Must specify both source environment and secret path" }); - throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { environment: updatedEnvironment, secretPath: updatedSecretPath }); @@ -374,10 +406,20 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretSyncActions.Delete, - ProjectPermissionSub.SecretSyncs - ); + if (secretSync.environment?.slug && secretSync.folder?.path) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Delete, + subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Delete, + ProjectPermissionSub.SecretSyncs + ); + } if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ @@ -441,10 +483,20 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretSyncActions.SyncSecrets, - ProjectPermissionSub.SecretSyncs - ); + if (secretSync.environment?.slug && secretSync.folder?.path) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.SyncSecrets, + subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.SyncSecrets, + ProjectPermissionSub.SecretSyncs + ); + } if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ @@ -503,10 +555,20 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretSyncActions.ImportSecrets, - ProjectPermissionSub.SecretSyncs - ); + if (secretSync.environment?.slug && secretSync.folder?.path) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.ImportSecrets, + subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.ImportSecrets, + ProjectPermissionSub.SecretSyncs + ); + } if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ @@ -559,10 +621,20 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretSyncActions.RemoveSecrets, - ProjectPermissionSub.SecretSyncs - ); + if (secretSync.environment?.slug && secretSync.folder?.path) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.RemoveSecrets, + subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.RemoveSecrets, + ProjectPermissionSub.SecretSyncs + ); + } if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncSourceFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncSourceFields.tsx index 7cc19ae97..850ebbc80 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncSourceFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncSourceFields.tsx @@ -1,17 +1,45 @@ +import { useEffect } from "react"; import { Controller, useFormContext } from "react-hook-form"; +import { subject } from "@casl/ability"; import { FilterableSelect, FormControl } from "@app/components/v2"; import { SecretPathInput } from "@app/components/v2/SecretPathInput"; -import { useWorkspace } from "@app/context"; +import { useProjectPermission, useWorkspace } from "@app/context"; +import { + ProjectPermissionSecretSyncActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; import { TSecretSyncForm } from "./schemas"; export const SecretSyncSourceFields = () => { - const { control, watch } = useFormContext(); + const { control, watch, setError, clearErrors } = useFormContext(); + const { permission } = useProjectPermission(); const { currentWorkspace } = useWorkspace(); const selectedEnvironment = watch("environment"); + const selectedSecretPath = watch("secretPath"); + + useEffect(() => { + const hasAccessToSource = + selectedEnvironment && + permission.can( + ProjectPermissionSecretSyncActions.Create, + subject(ProjectPermissionSub.SecretSyncs, { + environment: selectedEnvironment.slug, + secretPath: selectedSecretPath + }) + ); + + if (!hasAccessToSource) { + setError("secretPath", { + message: "You do not have permission to create secret syncs in this environment or path." + }); + } else { + clearErrors("secretPath"); + } + }, [selectedEnvironment, selectedSecretPath]); return ( <> diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index f9b2a828a..aa9087b1e 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -263,6 +263,11 @@ export type SecretImportSubjectFields = { secretPath: string; }; +export type SecretSyncSubjectFields = { + environment: string; + secretPath: string; +}; + export type SecretRotationSubjectFields = { environment: string; secretPath: string; @@ -303,6 +308,13 @@ export type ProjectPermissionSet = | (ForcedSubject & DynamicSecretSubjectFields) ) ] + | [ + ProjectPermissionSecretSyncActions, + ( + | ProjectPermissionSub.SecretSyncs + | (ForcedSubject & SecretSyncSubjectFields) + ) + ] | [ ProjectPermissionActions, ( @@ -365,7 +377,6 @@ export type ProjectPermissionSet = ] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] - | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index d7872a223..6d95c403c 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -291,6 +291,13 @@ export const projectRoleFormSchema = z.object({ }) .array() .default([]), + [ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.extend({ + inverted: z.boolean().optional(), + conditions: ConditionSchema + }) + .array() + .default([]), + [ProjectPermissionSub.Commits]: CommitPolicyActionSchema.array().default([]), [ProjectPermissionSub.Member]: MemberPolicyActionSchema.array().default([]), [ProjectPermissionSub.Groups]: GroupPolicyActionSchema.array().default([]), @@ -342,7 +349,6 @@ export const projectRoleFormSchema = z.object({ .default([]), [ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]), - [ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.array().default([]), [ProjectPermissionSub.Kmip]: KmipPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretScanningDataSources]: SecretScanningDataSourcePolicyActionSchema.array().default([]), @@ -366,7 +372,8 @@ type TConditionalFields = | ProjectPermissionSub.CertificateTemplates | ProjectPermissionSub.SshHosts | ProjectPermissionSub.SecretRotation - | ProjectPermissionSub.Identity; + | ProjectPermissionSub.Identity + | ProjectPermissionSub.SecretSyncs; export const isConditionalSubjects = ( subject: ProjectPermissionSub @@ -379,7 +386,8 @@ export const isConditionalSubjects = ( subject === ProjectPermissionSub.SshHosts || subject === ProjectPermissionSub.SecretRotation || subject === ProjectPermissionSub.PkiSubscribers || - subject === ProjectPermissionSub.CertificateTemplates; + subject === ProjectPermissionSub.CertificateTemplates || + subject === ProjectPermissionSub.SecretSyncs; const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => { const formConditions: z.infer = []; @@ -484,7 +492,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.SshCertificateTemplates, ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificates, - ProjectPermissionSub.SshHostGroups + ProjectPermissionSub.SshHostGroups, + ProjectPermissionSub.SecretSyncs ].includes(subject) ) { // from above statement we are sure it won't be undefined @@ -786,7 +795,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { const canImportSecrets = action.includes(ProjectPermissionSecretSyncActions.ImportSecrets); const canRemoveSecrets = action.includes(ProjectPermissionSecretSyncActions.RemoveSecrets); - if (!formVal[subject]) formVal[subject] = [{}]; + if (!formVal[subject]) formVal[subject] = [{ conditions: [], inverted: false }]; // from above statement we are sure it won't be undefined if (canRead) formVal[subject]![0][ProjectPermissionSecretSyncActions.Read] = true; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx index 31f63ac51..09f3920fd 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx @@ -35,6 +35,7 @@ import { TFormSchema } from "./ProjectRoleModifySection.utils"; import { SecretPermissionConditions } from "./SecretPermissionConditions"; +import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions"; import { SshHostPermissionConditions } from "./SshHostPermissionConditions"; type Props = { @@ -69,6 +70,10 @@ export const renderConditionalComponents = ( return ; } + if (subject === ProjectPermissionSub.SecretSyncs) { + return ; + } + return ; } diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretSyncPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretSyncPermissionConditions.tsx new file mode 100644 index 000000000..c017be65a --- /dev/null +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretSyncPermissionConditions.tsx @@ -0,0 +1,186 @@ +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 SecretSyncPermissionConditions = ({ position = 0, isDisabled }: Props) => { + const { + control, + watch, + setValue, + formState: { errors } + } = useFormContext(); + const items = useFieldArray({ + control, + name: `permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions` + }); + + const conditionErrorMessage = + errors?.permissions?.[ProjectPermissionSub.SecretSyncs]?.[position]?.conditions?.message || + errors?.permissions?.[ProjectPermissionSub.SecretSyncs]?.[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.SecretSyncs}.${position}.conditions.${index}` + ) as { + lhs: string; + rhs: string; + operator: string; + }; + return ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+ + + +
+
+
+ ( + + + + )} + /> +
+
+ items.remove(index)} + > + + +
+
+ ); + })} +
+ {conditionErrorMessage && ( +
+ + {conditionErrorMessage} +
+ )} +
+ +
+
+ ); +}; From 90e675de1e1b91c4dbc3524c761f160062b6be7c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Wed, 18 Jun 2025 00:22:25 +0400 Subject: [PATCH 2/6] docs(secret-syncs): add conditions support --- docs/internals/permissions/project-permissions.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index da9351188..c60db5afc 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -12,7 +12,7 @@ Each permission consists of: - **Subject**: The resource the permission applies to (e.g., secrets, members, settings) - **Action**: The operation that can be performed (e.g., read, create, edit, delete) -Some project-level resources—specifically `secrets`, `secret-folders`, `secret-imports`, and `dynamic-secrets`—support conditional permissions and permission inversion for more granular access control. Conditions allow you to specify criteria (like environment, secret path, or tags) that must be met for the permission to apply. +Some project-level resources—specifically `secrets`, `secret-folders`, `secret-imports`, `dynamic-secrets`, and `secret-syncs`, support conditional permissions and permission inversion for more granular access control. Conditions allow you to specify criteria (like environment, secret path, or tags) that must be met for the permission to apply. ## Available Project Permissions @@ -208,6 +208,8 @@ Supports conditions and permission inversion #### Subject: `secret-syncs` +Supports conditions and permission inversion. + | Action | Description | | ---------------- | -------------------------------------------------- | | `read` | View secret synchronization configurations | From b4c118d2469df37ed044be246952316bc1f8014d Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Wed, 18 Jun 2025 00:26:26 +0400 Subject: [PATCH 3/6] requested changes --- backend/src/ee/services/permission/project-permission.ts | 1 + .../components/SecretSyncPermissionConditions.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index f11f703fa..966146c27 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -857,6 +857,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ }), z.object({ subject: z.literal(ProjectPermissionSub.SecretSyncs).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretSyncActions).describe( "Describe what action an entity can take." ), diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretSyncPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretSyncPermissionConditions.tsx index c017be65a..b5f8f5c91 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretSyncPermissionConditions.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretSyncPermissionConditions.tsx @@ -145,7 +145,7 @@ export const SecretSyncPermissionConditions = ({ position = 0, isDisabled }: Pro
items.remove(index)} From e8cb3f8b4a23f3d22b0ff01cc55cc48cfea4761d Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 17 Jun 2025 16:18:41 -0700 Subject: [PATCH 4/6] improvements: fix secret sync policy parsing, add read checks/filters and disable ui based of conditions --- .../secret-sync/secret-sync-service.ts | 36 ++++++++++-- .../ProjectRoleModifySection.utils.tsx | 55 ++++++++++--------- .../SecretSyncTable/SecretSyncRow.tsx | 19 +++++-- .../components/SecretSyncActionTriggers.tsx | 21 +++++-- .../SecretSyncDestinatonSection.tsx | 16 ++++-- .../components/SecretSyncDetailsSection.tsx | 17 ++++-- .../SecretSyncOptionsSection.tsx | 18 ++++-- .../components/SecretSyncSourceSection.tsx | 14 +++-- 8 files changed, 137 insertions(+), 59 deletions(-) diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index 1bb27c05a..3fdb7fea6 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -89,7 +89,17 @@ export const secretSyncServiceFactory = ({ projectId }); - return secretSyncs as TSecretSync[]; + return secretSyncs.filter((secretSync) => + permission.can( + ProjectPermissionSecretSyncActions.Read, + secretSync.environment && secretSync.folder + ? subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + : ProjectPermissionSub.SecretSyncs + ) + ) as TSecretSync[]; }; const listSecretSyncsBySecretPath = async ( @@ -105,7 +115,15 @@ export const secretSyncServiceFactory = ({ projectId }); - if (permission.cannot(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs)) { + if ( + permission.cannot( + ProjectPermissionSecretSyncActions.Read, + subject(ProjectPermissionSub.SecretSyncs, { + environment, + secretPath + }) + ) + ) { return []; } @@ -142,7 +160,12 @@ export const secretSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretSyncActions.Read, - ProjectPermissionSub.SecretSyncs + secretSync.environment && secretSync.folder + ? subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + : ProjectPermissionSub.SecretSyncs ); if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) @@ -179,7 +202,12 @@ export const secretSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretSyncActions.Read, - ProjectPermissionSub.SecretSyncs + secretSync.environment && secretSync.folder + ? subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + : ProjectPermissionSub.SecretSyncs ); if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 6d95c403c..471246d04 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -524,6 +524,36 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } + if (subject === ProjectPermissionSub.SecretSyncs) { + const canRead = action.includes(ProjectPermissionSecretSyncActions.Read); + const canEdit = action.includes(ProjectPermissionSecretSyncActions.Edit); + const canDelete = action.includes(ProjectPermissionSecretSyncActions.Delete); + const canCreate = action.includes(ProjectPermissionSecretSyncActions.Create); + const canSyncSecrets = action.includes(ProjectPermissionSecretSyncActions.SyncSecrets); + const canImportSecrets = action.includes( + ProjectPermissionSecretSyncActions.ImportSecrets + ); + const canRemoveSecrets = action.includes( + ProjectPermissionSecretSyncActions.RemoveSecrets + ); + + if (!formVal[subject]) formVal[subject] = [{ conditions: [], inverted: false }]; + + // from above statement we are sure it won't be undefined + formVal[subject]!.push({ + [ProjectPermissionSecretSyncActions.Read]: canRead, + [ProjectPermissionSecretSyncActions.Create]: canCreate, + [ProjectPermissionSecretSyncActions.Edit]: canEdit, + [ProjectPermissionSecretSyncActions.Delete]: canDelete, + [ProjectPermissionSecretSyncActions.SyncSecrets]: canSyncSecrets, + [ProjectPermissionSecretSyncActions.ImportSecrets]: canImportSecrets, + [ProjectPermissionSecretSyncActions.RemoveSecrets]: canRemoveSecrets, + conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [], + inverted + }); + return; + } + if (subject === ProjectPermissionSub.DynamicSecrets) { const canRead = action.includes(ProjectPermissionDynamicSecretActions.ReadRootCredential); const canEdit = action.includes(ProjectPermissionDynamicSecretActions.EditRootCredential); @@ -786,31 +816,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } - if (subject === ProjectPermissionSub.SecretSyncs) { - const canRead = action.includes(ProjectPermissionSecretSyncActions.Read); - const canEdit = action.includes(ProjectPermissionSecretSyncActions.Edit); - const canDelete = action.includes(ProjectPermissionSecretSyncActions.Delete); - const canCreate = action.includes(ProjectPermissionSecretSyncActions.Create); - const canSyncSecrets = action.includes(ProjectPermissionSecretSyncActions.SyncSecrets); - const canImportSecrets = action.includes(ProjectPermissionSecretSyncActions.ImportSecrets); - const canRemoveSecrets = action.includes(ProjectPermissionSecretSyncActions.RemoveSecrets); - - if (!formVal[subject]) formVal[subject] = [{ conditions: [], inverted: false }]; - - // from above statement we are sure it won't be undefined - if (canRead) formVal[subject]![0][ProjectPermissionSecretSyncActions.Read] = true; - if (canEdit) formVal[subject]![0][ProjectPermissionSecretSyncActions.Edit] = true; - if (canCreate) formVal[subject]![0][ProjectPermissionSecretSyncActions.Create] = true; - if (canDelete) formVal[subject]![0][ProjectPermissionSecretSyncActions.Delete] = true; - if (canSyncSecrets) - formVal[subject]![0][ProjectPermissionSecretSyncActions.SyncSecrets] = true; - if (canImportSecrets) - formVal[subject]![0][ProjectPermissionSecretSyncActions.ImportSecrets] = true; - if (canRemoveSecrets) - formVal[subject]![0][ProjectPermissionSecretSyncActions.RemoveSecrets] = true; - return; - } - if (subject === ProjectPermissionSub.SecretScanningDataSources) { const canRead = action.includes(ProjectPermissionSecretScanningDataSourceActions.Read); const canEdit = action.includes(ProjectPermissionSecretScanningDataSourceActions.Edit); diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx index ddc74b128..3cec5694c 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import { subject } from "@casl/ability"; import { faBan, faCalendarCheck, @@ -117,6 +118,14 @@ export const SecretSyncRow = ({ const destinationDetails = SECRET_SYNC_MAP[destination]; + const permissionSubject = + environment && folder + ? subject(ProjectPermissionSub.SecretSyncs, { + environment: environment.slug, + secretPath: folder.path + }) + : ProjectPermissionSub.SecretSyncs; + return ( @@ -264,7 +273,7 @@ export const SecretSyncRow = ({ {(isAllowed: boolean) => ( {(isAllowed: boolean) => ( {(isAllowed: boolean) => ( {(isAllowed: boolean) => ( {(isAllowed: boolean) => ( { const triggerSyncSecrets = useTriggerSecretSyncSyncSecrets(); const updateSync = useUpdateSecretSync(); - const { destination } = secretSync; + const { destination, environment, folder } = secretSync; const destinationName = SECRET_SYNC_MAP[destination].name; const { syncOption } = useSecretSyncOption(destination); @@ -128,6 +129,14 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => { } }; + const permissionSubject = + environment && folder + ? subject(ProjectPermissionSub.SecretSyncs, { + environment: environment.slug, + secretPath: folder.path + }) + : ProjectPermissionSub.SecretSyncs; + return ( <>
@@ -157,7 +166,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
{(isAllowed: boolean) => (