Allow project approval workflows to set if a reviewer can or can not review their own requests

This commit is contained in:
carlosmonastyrski
2025-03-24 18:08:07 -03:00
parent f9a5b46365
commit 871be7132a
22 changed files with 316 additions and 182 deletions
@@ -0,0 +1,29 @@
import { Knex } from "knex";
import { TableName } from "../schemas/models";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "selfApprovals"))) {
await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => {
t.boolean("selfApprovals").notNullable().defaultTo(true);
});
}
if (!(await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "selfApprovals"))) {
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
t.boolean("selfApprovals").notNullable().defaultTo(true);
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "selfApprovals")) {
await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => {
t.dropColumn("selfApprovals");
});
}
if (await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "selfApprovals")) {
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
t.dropColumn("selfApprovals");
});
}
}
@@ -16,7 +16,8 @@ export const AccessApprovalPoliciesSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
enforcementLevel: z.string().default("hard"),
deletedAt: z.date().nullable().optional()
deletedAt: z.date().nullable().optional(),
selfApprovals: z.boolean().default(true)
});
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;
@@ -16,7 +16,8 @@ export const SecretApprovalPoliciesSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
enforcementLevel: z.string().default("hard"),
deletedAt: z.date().nullable().optional()
deletedAt: z.date().nullable().optional(),
selfApprovals: z.boolean().default(true)
});
export type TSecretApprovalPolicies = z.infer<typeof SecretApprovalPoliciesSchema>;
@@ -29,7 +29,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.array()
.min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).default(1),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard)
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
selfApprovals: z.boolean().default(true)
}),
response: {
200: z.object({
@@ -147,7 +148,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.array()
.min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).optional(),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard)
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
selfApprovals: z.boolean().default(true)
}),
response: {
200: z.object({
@@ -110,7 +110,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
secretPath: z.string().nullish(),
envId: z.string(),
enforcementLevel: z.string(),
deletedAt: z.date().nullish()
deletedAt: z.date().nullish(),
selfApprovals: z.boolean()
}),
reviewers: z
.object({
@@ -35,7 +35,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
.array()
.min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).default(1),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard)
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
selfApprovals: z.boolean().default(true)
}),
response: {
200: z.object({
@@ -85,7 +86,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
.nullable()
.transform((val) => (val ? removeTrailingSlash(val) : val))
.transform((val) => (val === "" ? "/" : val)),
enforcementLevel: z.nativeEnum(EnforcementLevel).optional()
enforcementLevel: z.nativeEnum(EnforcementLevel).optional(),
selfApprovals: z.boolean().default(true)
}),
response: {
200: z.object({
@@ -49,7 +49,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
.array(),
secretPath: z.string().optional().nullable(),
enforcementLevel: z.string(),
deletedAt: z.date().nullish()
deletedAt: z.date().nullish(),
selfApprovals: z.boolean()
}),
committerUser: approvalRequestUser,
commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(),
@@ -267,7 +268,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
approvers: approvalRequestUser.array(),
secretPath: z.string().optional().nullable(),
enforcementLevel: z.string(),
deletedAt: z.date().nullish()
deletedAt: z.date().nullish(),
selfApprovals: z.boolean()
}),
environment: z.string(),
statusChangedByUser: approvalRequestUser.optional(),
@@ -65,7 +65,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvers,
projectSlug,
environment,
enforcementLevel
enforcementLevel,
selfApprovals
}: TCreateAccessApprovalPolicy) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -153,7 +154,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvals,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
},
tx
);
@@ -216,7 +218,8 @@ export const accessApprovalPolicyServiceFactory = ({
actorOrgId,
actorAuthMethod,
approvals,
enforcementLevel
enforcementLevel,
selfApprovals
}: TUpdateAccessApprovalPolicy) => {
const groupApprovers = approvers
.filter((approver) => approver.type === ApproverType.Group)
@@ -262,7 +265,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvals,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
},
tx
);
@@ -26,6 +26,7 @@ export type TCreateAccessApprovalPolicy = {
projectSlug: string;
name: string;
enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalPolicy = {
@@ -35,6 +36,7 @@ export type TUpdateAccessApprovalPolicy = {
secretPath?: string;
name?: string;
enforcementLevel?: EnforcementLevel;
selfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteAccessApprovalPolicy = {
@@ -61,6 +61,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
db.ref("selfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policySelfApprovals"),
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
)
@@ -119,6 +120,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approvals: doc.policyApprovals,
secretPath: doc.policySecretPath,
enforcementLevel: doc.policyEnforcementLevel,
selfApprovals: doc.policySelfApprovals,
envId: doc.policyEnvId,
deletedAt: doc.policyDeletedAt
},
@@ -254,6 +256,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("slug").withSchema(TableName.Environment).as("environment"),
tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
tx.ref("selfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policySelfApprovals"),
tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
);
@@ -275,6 +278,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approvals: el.policyApprovals,
secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel,
selfApprovals: el.policySelfApprovals,
deletedAt: el.policyDeletedAt
},
requestedByUser: {
@@ -320,6 +320,11 @@ export const accessApprovalRequestServiceFactory = ({
message: "The policy associated with this access request has been deleted."
});
}
if (!policy.selfApprovals && actorId === accessApprovalRequest.requestedByUserId) {
throw new BadRequestError({
message: "Failed to review access approval request. Users are not authorized to review their own request."
});
}
const { membership, hasRole } = await permissionService.getProjectPermission({
actor,
@@ -62,7 +62,8 @@ export const secretApprovalPolicyServiceFactory = ({
projectId,
secretPath,
environment,
enforcementLevel
enforcementLevel,
selfApprovals
}: TCreateSapDTO) => {
const groupApprovers = approvers
?.filter((approver) => approver.type === ApproverType.Group)
@@ -113,7 +114,8 @@ export const secretApprovalPolicyServiceFactory = ({
approvals,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
},
tx
);
@@ -172,7 +174,8 @@ export const secretApprovalPolicyServiceFactory = ({
actorAuthMethod,
approvals,
secretPolicyId,
enforcementLevel
enforcementLevel,
selfApprovals
}: TUpdateSapDTO) => {
const groupApprovers = approvers
?.filter((approver) => approver.type === ApproverType.Group)
@@ -218,7 +221,8 @@ export const secretApprovalPolicyServiceFactory = ({
approvals,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
},
tx
);
@@ -10,6 +10,7 @@ export type TCreateSapDTO = {
projectId: string;
name: string;
enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateSapDTO = {
@@ -19,6 +20,7 @@ export type TUpdateSapDTO = {
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
name?: string;
enforcementLevel?: EnforcementLevel;
selfApprovals?: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteSapDTO = {
@@ -112,6 +112,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
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("selfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policySelfApprovals"),
tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt")
);
@@ -150,7 +151,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel,
envId: el.policyEnvId,
deletedAt: el.policyDeletedAt
deletedAt: el.policyDeletedAt,
selfApprovals: el.policySelfApprovals
}
}),
childrenMapper: [
@@ -336,6 +338,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
),
db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
db.ref("selfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policySelfApprovals"),
db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"),
@@ -364,7 +367,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
name: el.policyName,
approvals: el.policyApprovals,
secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel
enforcementLevel: el.policyEnforcementLevel,
selfApprovals: el.policySelfApprovals
},
committerUser: {
userId: el.committerUserId,
@@ -482,6 +486,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
`DENSE_RANK() OVER (partition by ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."id" DESC) as rank`
),
db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
db.ref("selfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policySelfApprovals"),
db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
@@ -511,7 +516,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
name: el.policyName,
approvals: el.policyApprovals,
secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel
enforcementLevel: el.policyEnforcementLevel,
selfApprovals: el.policySelfApprovals
},
committerUser: {
userId: el.committerUserId,
@@ -352,6 +352,11 @@ export const secretApprovalRequestServiceFactory = ({
message: "The policy associated with this secret approval request has been deleted."
});
}
if (!policy.selfApprovals && actorId === secretApprovalRequest.committerUserId) {
throw new BadRequestError({
message: "Failed to review secret approval request. Users are not authorized to review their own request."
});
}
const { hasRole } = await permissionService.getProjectPermission({
actor: ActorType.USER,
@@ -23,7 +23,8 @@ export const useCreateAccessApprovalPolicy = () => {
approvers,
name,
secretPath,
enforcementLevel
enforcementLevel,
selfApprovals
}) => {
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
environment,
@@ -32,7 +33,8 @@ export const useCreateAccessApprovalPolicy = () => {
approvers,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
});
return data;
},
@@ -48,13 +50,22 @@ export const useUpdateAccessApprovalPolicy = () => {
const queryClient = useQueryClient();
return useMutation<object, object, TUpdateAccessPolicyDTO>({
mutationFn: async ({ id, approvers, approvals, name, secretPath, enforcementLevel }) => {
mutationFn: async ({
id,
approvers,
approvals,
name,
secretPath,
enforcementLevel,
selfApprovals
}) => {
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
approvals,
approvers,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
});
return data;
},
@@ -16,6 +16,7 @@ export type TAccessApprovalPolicy = {
enforcementLevel: EnforcementLevel;
updatedAt: Date;
approvers?: Approver[];
selfApprovals: boolean;
};
export enum ApproverType {
@@ -71,6 +72,7 @@ export type TAccessApprovalRequest = {
envId: string;
enforcementLevel: EnforcementLevel;
deletedAt: Date | null;
selfApprovals: boolean;
};
reviewers: {
@@ -144,6 +146,7 @@ export type TCreateAccessPolicyDTO = {
approvals?: number;
secretPath?: string;
enforcementLevel?: EnforcementLevel;
selfApprovals: boolean;
};
export type TUpdateAccessPolicyDTO = {
@@ -154,6 +157,7 @@ export type TUpdateAccessPolicyDTO = {
environment?: string;
approvals?: number;
enforcementLevel?: EnforcementLevel;
selfApprovals: boolean;
// for invalidating list
projectSlug: string;
};
@@ -16,7 +16,8 @@ export const useCreateSecretApprovalPolicy = () => {
approvers,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
}) => {
const { data } = await apiRequest.post("/api/v1/secret-approvals", {
environment,
@@ -25,7 +26,8 @@ export const useCreateSecretApprovalPolicy = () => {
approvers,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
});
return data;
},
@@ -41,13 +43,22 @@ export const useUpdateSecretApprovalPolicy = () => {
const queryClient = useQueryClient();
return useMutation<object, object, TUpdateSecretPolicyDTO>({
mutationFn: async ({ id, approvers, approvals, secretPath, name, enforcementLevel }) => {
mutationFn: async ({
id,
approvers,
approvals,
secretPath,
name,
enforcementLevel,
selfApprovals
}) => {
const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, {
approvals,
approvers,
secretPath,
name,
enforcementLevel
enforcementLevel,
selfApprovals
});
return data;
},
@@ -12,6 +12,7 @@ export type TSecretApprovalPolicy = {
approvers: Approver[];
updatedAt: Date;
enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
};
export enum ApproverType {
@@ -42,6 +43,7 @@ export type TCreateSecretPolicyDTO = {
approvers?: Approver[];
approvals?: number;
enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
};
export type TUpdateSecretPolicyDTO = {
@@ -50,6 +52,7 @@ export type TUpdateSecretPolicyDTO = {
approvers?: Approver[];
secretPath?: string | null;
approvals?: number;
selfApprovals?: boolean;
enforcementLevel?: EnforcementLevel;
// for invalidating list
workspaceId: string;
@@ -152,7 +152,7 @@ export const AccessApprovalRequest = ({
const isAccepted = request.isApproved;
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const isRequestedByCurrentUser = request.requestedByUserId === user.id;
const isSelfApproveAllowed = request.policy.selfApprovals;
const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status;
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
@@ -189,7 +189,8 @@ export const AccessApprovalRequest = ({
userReviewStatus,
isAccepted,
isSoftEnforcement,
isRequestedByCurrentUser
isRequestedByCurrentUser,
isSelfApproveAllowed
};
};
@@ -342,7 +343,7 @@ export const AccessApprovalRequest = ({
tabIndex={0}
onClick={() => {
if (
(!details.isApprover ||
((!details.isApprover ||
details.isReviewedByUser ||
details.isRejectedByAnyone ||
details.isAccepted) &&
@@ -350,7 +351,8 @@ export const AccessApprovalRequest = ({
details.isSoftEnforcement &&
details.isRequestedByCurrentUser &&
!details.isAccepted
)
)) ||
(request.requestedByUserId === user.id && !details.isSelfApproveAllowed)
)
return;
if (membersGroupById?.[request.requestedByUserId].user) {
@@ -12,7 +12,8 @@ import {
Modal,
ModalContent,
Select,
SelectItem
SelectItem,
Switch
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { getMemberLabel } from "@app/helpers/members";
@@ -54,7 +55,8 @@ const formSchema = z
.array()
.default([]),
policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel)
enforcementLevel: z.nativeEnum(EnforcementLevel),
selfApprovals: z.boolean().default(true)
})
.superRefine((data, ctx) => {
if (!(data.groupApprovers.length || data.userApprovers.length)) {
@@ -101,7 +103,8 @@ export const AccessPolicyForm = ({
editValues?.approvers
?.filter((approver) => approver.type === ApproverType.Group)
.map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [],
approvals: editValues?.approvals
approvals: editValues?.approvals,
selfApprovals: editValues?.selfApprovals
}
: undefined
});
@@ -441,6 +444,27 @@ export const AccessPolicyForm = ({
</FormControl>
)}
/>
<Controller
control={control}
name="selfApprovals"
defaultValue
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Self Approvals"
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
id="self-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Allow approvers to review their own requests
</Switch>
</FormControl>
)}
/>
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save
@@ -127,7 +127,9 @@ export const SecretApprovalRequestChanges = ({
} = useForm<TReviewFormSchema>({
resolver: zodResolver(reviewFormSchema)
});
const shouldBlockSelfReview =
secretApprovalRequestDetails?.policy?.selfApprovals === false &&
secretApprovalRequestDetails?.committerUserId === userSession.id;
const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus;
const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus;
@@ -245,7 +247,9 @@ export const SecretApprovalRequestChanges = ({
</div>
</div>
</div>
{!hasMerged && secretApprovalRequestDetails.status === "open" && (
{!hasMerged &&
secretApprovalRequestDetails.status === "open" &&
!shouldBlockSelfReview && (
<DropdownMenu
open={popUp.reviewChanges.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)}
@@ -422,7 +426,12 @@ export const SecretApprovalRequestChanges = ({
<div className="sticky top-0 w-1/5 pt-4" style={{ minWidth: "240px" }}>
<div className="text-sm text-bunker-300">Reviewers</div>
<div className="mt-2 flex flex-col space-y-2 text-sm">
{secretApprovalRequestDetails?.policy?.approvers.map((requiredApprover) => {
{secretApprovalRequestDetails?.policy?.approvers
.filter(
(requiredApprover) =>
!(shouldBlockSelfReview && requiredApprover.userId === userSession.id)
)
.map((requiredApprover) => {
const reviewer = reviewedUsers?.[requiredApprover.userId];
return (
<div