Merge pull request #2153 from aheruz/feat/secret-request-bypass-reason
feat: add bypass reason on bypassed secret requests
@@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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<void> {
|
||||
const hasColumn = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "bypassReason");
|
||||
if (hasColumn) {
|
||||
await knex.schema.table(TableName.SecretApprovalRequest, (table) => {
|
||||
table.dropColumn("bypassReason");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<TSecretSnapshotServiceFactory, "performSnapshot">;
|
||||
secretVersionDAL: Pick<TSecretVersionDALFactory, "findLatestVersionMany" | "insertMany">;
|
||||
secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "insertMany">;
|
||||
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">;
|
||||
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus" | "findProjectById">;
|
||||
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "removeSecretReminder">;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
userDAL: Pick<TUserDALFactory, "find" | "findOne">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||
};
|
||||
|
||||
export type TSecretApprovalRequestServiceFactory = ReturnType<typeof secretApprovalRequestServiceFactory>;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export type TGenerateSecretApprovalRequestDTO = {
|
||||
|
||||
export type TMergeSecretApprovalRequestDTO = {
|
||||
approvalId: string;
|
||||
bypassReason?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TStatusChangeDTO = {
|
||||
|
||||
@@ -755,7 +755,10 @@ export const registerRoutes = async (
|
||||
secretApprovalRequestDAL,
|
||||
snapshotService,
|
||||
secretVersionTagDAL,
|
||||
secretQueueService
|
||||
secretQueueService,
|
||||
smtpService,
|
||||
userDAL,
|
||||
projectEnvDAL
|
||||
});
|
||||
|
||||
const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Secret Approval Request Policy Bypassed</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Infisical</h1>
|
||||
<h2>Secret Approval Request Bypassed</h2>
|
||||
<p>A secret approval request has been bypassed in the project "{{projectName}}".</p>
|
||||
|
||||
<p>
|
||||
{{requesterFullName}} ({{requesterEmail}}) has merged
|
||||
a secret to environment {{environment}} at secret path {{secretPath}}
|
||||
without obtaining the required approvals.
|
||||
</p>
|
||||
<p>
|
||||
The following reason was provided for bypassing the policy:
|
||||
<em>{{bypassReason}}</em>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To review this action, please visit the request panel
|
||||
<a href="{{approvalUrl}}">here</a>.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
|
||||

|
||||

|
||||
|
||||
@@ -14,9 +14,14 @@ This functionality works in the following way:
|
||||

|
||||

|
||||
|
||||
3. An eligible approver can approve or reject the access request.
|
||||

|
||||
4. An eligible approver can approve or reject the access request.
|
||||
{/*  */}
|
||||

|
||||
|
||||
4. As soon as the request is approved, developer is able to access the sought resources.
|
||||
<Info>
|
||||
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.
|
||||
</Info>
|
||||
|
||||
5. As soon as the request is approved, developer is able to access the sought resources.
|
||||

|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
### 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)**.
|
||||
|
||||

|
||||
|
||||
### 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).
|
||||
|
||||

|
||||
|
||||
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)).
|
||||
|
||||

|
||||
BIN
docs/images/platform/access-controls/access-request-bypass.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 43 KiB |
BIN
docs/images/platform/pr-workflows/create-change-policy.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 55 KiB |
@@ -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 }) => {
|
||||
|
||||
@@ -133,4 +133,5 @@ export type TUpdateSecretApprovalRequestStatusDTO = {
|
||||
export type TPerformSecretApprovalRequestMerge = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
bypassReason?: string;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex w-full items-start justify-between transition-all">
|
||||
<div className="flex items-start space-x-4">
|
||||
<FontAwesomeIcon
|
||||
icon={isMergable ? faSquareCheck : faSquareXmark}
|
||||
@@ -105,18 +113,33 @@ export const SecretApprovalRequestAction = ({
|
||||
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
|
||||
</span>
|
||||
{!canApprove && isSoftEnforcement && (
|
||||
<div className="mt-1">
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => 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" : "")}
|
||||
>
|
||||
<span className="text-red text-sm">
|
||||
<span className="text-red text-xs">
|
||||
Merge without waiting for approval (bypass secret change policy)
|
||||
</span>
|
||||
</Checkbox>
|
||||
{byPassApproval && (
|
||||
<FormControl
|
||||
label="Reason for bypass"
|
||||
className="mt-2"
|
||||
isRequired
|
||||
tooltipText="Enter a reason for bypassing the secret change policy"
|
||||
>
|
||||
<Input
|
||||
value={bypassReason}
|
||||
onChange={(e) => setBypassReason(e.target.value)}
|
||||
placeholder="Enter reason for bypass (min 10 chars)"
|
||||
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
@@ -137,7 +160,7 @@ export const SecretApprovalRequestAction = ({
|
||||
leftIcon={<FontAwesomeIcon icon={!canApprove ? faLandMineOn : faCheck} />}
|
||||
isDisabled={
|
||||
(!isMergable && canApprove)
|
||||
|| (!canApprove && isSoftEnforcement && !byPassApproval)
|
||||
|| (!canApprove && isSoftEnforcement && (!byPassApproval || !isValidBypassReason(bypassReason)))
|
||||
}
|
||||
isLoading={isMerging}
|
||||
onClick={handleSecretApprovalRequestMerge}
|
||||
|
||||