diff --git a/backend/src/db/migrations/20240719182539_add-bypass-reason-secret-approval-requets.ts b/backend/src/db/migrations/20240719182539_add-bypass-reason-secret-approval-requets.ts new file mode 100644 index 0000000000..6b688dbc39 --- /dev/null +++ b/backend/src/db/migrations/20240719182539_add-bypass-reason-secret-approval-requets.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "bypassReason"); + if (!hasColumn) { + await knex.schema.table(TableName.SecretApprovalRequest, (table) => { + table.string("bypassReason").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "bypassReason"); + if (hasColumn) { + await knex.schema.table(TableName.SecretApprovalRequest, (table) => { + table.dropColumn("bypassReason"); + }); + } +} diff --git a/backend/src/db/schemas/secret-approval-requests.ts b/backend/src/db/schemas/secret-approval-requests.ts index aa6896d106..7ca0b71d9f 100644 --- a/backend/src/db/schemas/secret-approval-requests.ts +++ b/backend/src/db/schemas/secret-approval-requests.ts @@ -15,6 +15,7 @@ export const SecretApprovalRequestsSchema = z.object({ conflicts: z.unknown().nullable().optional(), slug: z.string(), folderId: z.string().uuid(), + bypassReason: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), isReplicated: z.boolean().nullable().optional(), diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 8e0b8a6823..0ba7099a3b 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -117,6 +117,9 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv params: z.object({ id: z.string() }), + body: z.object({ + bypassReason: z.string().optional() + }), response: { 200: z.object({ approval: SecretApprovalRequestsSchema @@ -130,7 +133,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - approvalId: req.params.id + approvalId: req.params.id, + bypassReason: req.body.bypassReason }); return { approval }; } diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 4ce174d269..855442e6ff 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -94,6 +94,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("projectId").withSchema(TableName.Environment), tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), + tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"), tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals") ); @@ -130,7 +131,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { name: el.policyName, approvals: el.policyApprovals, secretPath: el.policySecretPath, - enforcementLevel: el.policyEnforcementLevel + enforcementLevel: el.policyEnforcementLevel, + envId: el.policyEnvId } }), childrenMapper: [ diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 9f824b2b15..68e0271d26 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -7,6 +7,7 @@ import { SecretType, TSecretApprovalRequestsSecretsInsert } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { groupBy, pick, unique } from "@app/lib/fn"; @@ -15,6 +16,7 @@ import { EnforcementLevel } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; +import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TSecretDALFactory } from "@app/services/secret/secret-dal"; import { fnSecretBlindIndexCheck, @@ -31,6 +33,8 @@ import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; @@ -63,8 +67,11 @@ type TSecretApprovalRequestServiceFactoryDep = { snapshotService: Pick; secretVersionDAL: Pick; secretVersionTagDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; secretQueueService: Pick; + smtpService: Pick; + userDAL: Pick; + projectEnvDAL: Pick; }; export type TSecretApprovalRequestServiceFactory = ReturnType; @@ -83,7 +90,10 @@ export const secretApprovalRequestServiceFactory = ({ snapshotService, secretVersionDAL, secretQueueService, - projectBotService + projectBotService, + smtpService, + userDAL, + projectEnvDAL }: TSecretApprovalRequestServiceFactoryDep) => { const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); @@ -258,7 +268,8 @@ export const secretApprovalRequestServiceFactory = ({ actor, actorId, actorOrgId, - actorAuthMethod + actorAuthMethod, + bypassReason }: TMergeSecretApprovalRequestDTO) => { const secretApprovalRequest = await secretApprovalRequestDAL.findById(approvalId); if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); @@ -470,7 +481,8 @@ export const secretApprovalRequestServiceFactory = ({ conflicts: JSON.stringify(conflicts), hasMerged: true, status: RequestState.Closed, - statusChangedByUserId: actorId + statusChangedByUserId: actorId, + bypassReason }, tx ); @@ -489,6 +501,35 @@ export const secretApprovalRequestServiceFactory = ({ actorId, actor }); + + if (isSoftEnforcement) { + const cfg = getConfig(); + const project = await projectDAL.findProjectById(projectId); + const env = await projectEnvDAL.findOne({ id: policy.envId }); + const requestedByUser = await userDAL.findOne({ id: actorId }); + const approverUsers = await userDAL.find({ + $in: { + id: policy.approvers.map((approver: { userId: string }) => approver.userId) + } + }); + + await smtpService.sendMail({ + recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!), + subjectLine: "Infisical Secret Change Policy Bypassed", + + substitutions: { + projectName: project.name, + requesterFullName: `${requestedByUser.firstName} ${requestedByUser.lastName}`, + requesterEmail: requestedByUser.email, + bypassReason, + secretPath: policy.secretPath, + environment: env.name, + approvalUrl: `${cfg.SITE_URL}/project/${project.id}/approval` + }, + template: SmtpTemplates.AccessSecretRequestBypassed + }); + } + return mergeStatus; }; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts index 1fbb754184..db3dc176b4 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts @@ -39,6 +39,7 @@ export type TGenerateSecretApprovalRequestDTO = { export type TMergeSecretApprovalRequestDTO = { approvalId: string; + bypassReason?: string; } & Omit; export type TStatusChangeDTO = { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 3424e7134a..59aa8d3014 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -755,7 +755,10 @@ export const registerRoutes = async ( secretApprovalRequestDAL, snapshotService, secretVersionTagDAL, - secretQueueService + secretQueueService, + smtpService, + userDAL, + projectEnvDAL }); const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 1fb89c5537..1511274841 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -23,6 +23,7 @@ export enum SmtpTemplates { EmailMfa = "emailMfa.handlebars", UnlockAccount = "unlockAccount.handlebars", AccessApprovalRequest = "accessApprovalRequest.handlebars", + AccessSecretRequestBypassed = "accessSecretRequestBypassed.handlebars", HistoricalSecretList = "historicalSecretLeakIncident.handlebars", NewDeviceJoin = "newDevice.handlebars", OrgInvite = "organizationInvitation.handlebars", diff --git a/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars b/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars new file mode 100644 index 0000000000..3313d352f3 --- /dev/null +++ b/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars @@ -0,0 +1,28 @@ + + + + + Secret Approval Request Policy Bypassed + + + +

Infisical

+

Secret Approval Request Bypassed

+

A secret approval request has been bypassed in the project "{{projectName}}".

+ +

+ {{requesterFullName}} ({{requesterEmail}}) has merged + a secret to environment {{environment}} at secret path {{secretPath}} + without obtaining the required approvals. +

+

+ The following reason was provided for bypassing the policy: + {{bypassReason}} +

+ +

+ To review this action, please visit the request panel + here. +

+ + \ No newline at end of file diff --git a/docs/documentation/platform/access-controls/access-requests.mdx b/docs/documentation/platform/access-controls/access-requests.mdx index 45c155ab4e..76cc4b74eb 100644 --- a/docs/documentation/platform/access-controls/access-requests.mdx +++ b/docs/documentation/platform/access-controls/access-requests.mdx @@ -6,7 +6,7 @@ description: "Learn how to request access to sensitive resources in Infisical." In certain situations, developers need to expand their access to a certain new project or a sensitive environment. For those use cases, it is helpful to utilize Infisical's **Access Requests** functionality. This functionality works in the following way: -1. A project administrator sets up a policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment. +1. A project administrator sets up an access policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment. ![Create Access Request Policy Modal](/images/platform/access-controls/create-access-request-policy.png) ![Access Request Policies](/images/platform/access-controls/access-request-policies.png) @@ -14,9 +14,14 @@ This functionality works in the following way: ![Access Request Create](/images/platform/access-controls/request-access.png) ![Access Request Dashboard](/images/platform/access-controls/access-requests-pending.png) -3. An eligible approver can approve or reject the access request. -![Access Request Review](/images/platform/access-controls/review-access-request.png) +4. An eligible approver can approve or reject the access request. +{/* ![Access Request Review](/images/platform/access-controls/review-access-request.png) */} +![Access Request Bypass](/images/platform/access-controls/access-request-bypass.png) -4. As soon as the request is approved, developer is able to access the sought resources. + + If the access request matches with a policy that has a **Soft** enforcement level, the requester may bypass the policy and get access to the resource without full approval. + + +5. As soon as the request is approved, developer is able to access the sought resources. ![Access Request Dashboard](/images/platform/access-controls/access-requests-completed.png) diff --git a/docs/documentation/platform/pr-workflows.mdx b/docs/documentation/platform/pr-workflows.mdx index 9df1236127..d582c838bf 100644 --- a/docs/documentation/platform/pr-workflows.mdx +++ b/docs/documentation/platform/pr-workflows.mdx @@ -18,16 +18,26 @@ In a similar way, to solve the above-mentioned issues, Infisical provides a feat ### Setting a policy -First, you would need to create a set of policies for a certain environment. In the example below, a generic policy for a production environment is shown. In this case, any user who submits a change to `prod` would first have to get an approval by a predefined approver (or multiple approvers). +First, you would need to create a set of policies for a certain environment. In the example below, a generic change policy for a production environment is shown. In this case, any user who submits a change to `prod` would first have to get an approval by a predefined approver (or multiple approvers). ![create secret update policy](../../images/platform/pr-workflows/secret-update-policy.png) +### Policy enforcement levels + +The enforcement level determines how strict the policy is. A **Hard** enforcement level means that any change that matches the policy will need full approval prior merging. A **Soft** enforcement level allows for break glass functionality on the request. If a change request is bypassed, the approvers will be notified via email. + +### Example of creating a change policy + +When creating a policy, you can choose the type of policy you want to create. In this case, we will be creating a `Change Policy`. Other types of policies include `Access Policy` that creates policies for **[Access Requests](/documentation/platform/access-controls/access-requests)**. + +![create panel secret update policy](../../images/platform/pr-workflows/create-change-policy.png) + ### Example of updating secrets with Approval workflows When a user submits a change to an enviropnment that is under a particular policy, a corresponsing change request will go to a predefined approver (or multiple approvers). ![secret update change requests](../../images/platform/pr-workflows/secret-update-request.png) -An approver is notified by email and/or Slack as soon as the request is initiated. In the Infisical Dashboard, they will be able to `approve` and `merge` (or `deny`) a request for a change in a particular environment. After that, depending on the workflows setup, the change will be automatically propagated to the right applications (e.g., using [Infisical Kubernetes Operator](https://infisical.com/docs/integrations/platforms/kubernetes)). +Approvers are notified by email and/or Slack as soon as the request is initiated. In the Infisical Dashboard, they will be able to `approve` and `merge` (or `deny`) a request for a change in a particular environment. After that, depending on the workflows setup, the change will be automatically propagated to the right applications (e.g., using [Infisical Kubernetes Operator](https://infisical.com/docs/integrations/platforms/kubernetes)). ![secrets update pull request](../../images/platform/pr-workflows/secret-update-pr.png) \ No newline at end of file diff --git a/docs/images/platform/access-controls/access-request-bypass.png b/docs/images/platform/access-controls/access-request-bypass.png new file mode 100644 index 0000000000..2481505742 Binary files /dev/null and b/docs/images/platform/access-controls/access-request-bypass.png differ diff --git a/docs/images/platform/access-controls/access-request-policies.png b/docs/images/platform/access-controls/access-request-policies.png index d7ea4829c9..a0eca9dfde 100644 Binary files a/docs/images/platform/access-controls/access-request-policies.png and b/docs/images/platform/access-controls/access-request-policies.png differ diff --git a/docs/images/platform/access-controls/create-access-request-policy.png b/docs/images/platform/access-controls/create-access-request-policy.png index 6593fd7334..56f9840cf0 100644 Binary files a/docs/images/platform/access-controls/create-access-request-policy.png and b/docs/images/platform/access-controls/create-access-request-policy.png differ diff --git a/docs/images/platform/pr-workflows/create-change-policy.png b/docs/images/platform/pr-workflows/create-change-policy.png new file mode 100644 index 0000000000..4ff1ad884b Binary files /dev/null and b/docs/images/platform/pr-workflows/create-change-policy.png differ diff --git a/docs/images/platform/pr-workflows/secret-update-policy.png b/docs/images/platform/pr-workflows/secret-update-policy.png index 45e6322f18..53a4e92ca0 100644 Binary files a/docs/images/platform/pr-workflows/secret-update-policy.png and b/docs/images/platform/pr-workflows/secret-update-policy.png differ diff --git a/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx b/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx index 94358ed798..f3b3896446 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx @@ -46,8 +46,10 @@ export const usePerformSecretApprovalRequestMerge = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TPerformSecretApprovalRequestMerge>({ - mutationFn: async ({ id }) => { - const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}/merge`); + mutationFn: async ({ id, bypassReason }) => { + const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}/merge`, { + bypassReason + }); return data; }, onSuccess: (_, { id, workspaceId }) => { diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index f039cc815e..ba7cbc38d8 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -133,4 +133,5 @@ export type TUpdateSecretApprovalRequestStatusDTO = { export type TPerformSecretApprovalRequestMerge = { id: string; workspaceId: string; + bypassReason?: string; }; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx index f3b9d7b1e6..9e2400d2db 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -6,12 +6,13 @@ import { faLockOpen, faSquareCheck, faSquareXmark, + faTriangleExclamation, faUserLock} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { Button, Checkbox } from "@app/components/v2"; +import { Button, Checkbox, FormControl, Input } from "@app/components/v2"; import { usePerformSecretApprovalRequestMerge, useUpdateSecretApprovalRequestStatus @@ -48,12 +49,19 @@ export const SecretApprovalRequestAction = ({ useUpdateSecretApprovalRequestStatus(); const [byPassApproval, setByPassApproval] = useState(false); + const [bypassReason, setBypassReason] = useState(""); + + const isValidBypassReason = (value: string) => { + const trimmedValue = value.trim(); + return trimmedValue.length >= 10; + }; const handleSecretApprovalRequestMerge = async () => { try { await performSecretApprovalMerge({ id: approvalRequestId, - workspaceId + workspaceId, + bypassReason: byPassApproval ? bypassReason : undefined }); createNotification({ type: "success", @@ -92,7 +100,7 @@ export const SecretApprovalRequestAction = ({ if (!hasMerged && status === "open") { return ( -
+
{!canApprove && isSoftEnforcement && ( -
+
setByPassApproval(checked === true)} isChecked={byPassApproval} id="byPassApproval" checkIndicatorBg="text-white" - className={byPassApproval ? "bg-red hover:bg-red-600 border-red" : ""} + className={twMerge("mr-2", byPassApproval ? "bg-red hover:bg-red-600 border-red" : "")} > - + Merge without waiting for approval (bypass secret change policy) + {byPassApproval && ( + + setBypassReason(e.target.value)} + placeholder="Enter reason for bypass (min 10 chars)" + leftIcon={} + /> + + )}
)} @@ -137,7 +160,7 @@ export const SecretApprovalRequestAction = ({ leftIcon={} isDisabled={ (!isMergable && canApprove) - || (!canApprove && isSoftEnforcement && !byPassApproval) + || (!canApprove && isSoftEnforcement && (!byPassApproval || !isValidBypassReason(bypassReason))) } isLoading={isMerging} onClick={handleSecretApprovalRequestMerge}