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(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
enforcementLevel: z.string().default("hard"), 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>; export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;
@@ -16,7 +16,8 @@ export const SecretApprovalPoliciesSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
enforcementLevel: z.string().default("hard"), 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>; export type TSecretApprovalPolicies = z.infer<typeof SecretApprovalPoliciesSchema>;
@@ -29,7 +29,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.array() .array()
.min(1, { message: "At least one approver should be provided" }), .min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).default(1), 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: { response: {
200: z.object({ 200: z.object({
@@ -147,7 +148,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.array() .array()
.min(1, { message: "At least one approver should be provided" }), .min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).optional(), 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: { response: {
200: z.object({ 200: z.object({
@@ -110,7 +110,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
secretPath: z.string().nullish(), secretPath: z.string().nullish(),
envId: z.string(), envId: z.string(),
enforcementLevel: z.string(), enforcementLevel: z.string(),
deletedAt: z.date().nullish() deletedAt: z.date().nullish(),
selfApprovals: z.boolean()
}), }),
reviewers: z reviewers: z
.object({ .object({
@@ -35,7 +35,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
.array() .array()
.min(1, { message: "At least one approver should be provided" }), .min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).default(1), 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: { response: {
200: z.object({ 200: z.object({
@@ -85,7 +86,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
.nullable() .nullable()
.transform((val) => (val ? removeTrailingSlash(val) : val)) .transform((val) => (val ? removeTrailingSlash(val) : val))
.transform((val) => (val === "" ? "/" : val)), .transform((val) => (val === "" ? "/" : val)),
enforcementLevel: z.nativeEnum(EnforcementLevel).optional() enforcementLevel: z.nativeEnum(EnforcementLevel).optional(),
selfApprovals: z.boolean().default(true)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -49,7 +49,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
.array(), .array(),
secretPath: z.string().optional().nullable(), secretPath: z.string().optional().nullable(),
enforcementLevel: z.string(), enforcementLevel: z.string(),
deletedAt: z.date().nullish() deletedAt: z.date().nullish(),
selfApprovals: z.boolean()
}), }),
committerUser: approvalRequestUser, committerUser: approvalRequestUser,
commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), 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(), approvers: approvalRequestUser.array(),
secretPath: z.string().optional().nullable(), secretPath: z.string().optional().nullable(),
enforcementLevel: z.string(), enforcementLevel: z.string(),
deletedAt: z.date().nullish() deletedAt: z.date().nullish(),
selfApprovals: z.boolean()
}), }),
environment: z.string(), environment: z.string(),
statusChangedByUser: approvalRequestUser.optional(), statusChangedByUser: approvalRequestUser.optional(),
@@ -65,7 +65,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvers, approvers,
projectSlug, projectSlug,
environment, environment,
enforcementLevel enforcementLevel,
selfApprovals
}: TCreateAccessApprovalPolicy) => { }: TCreateAccessApprovalPolicy) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -153,7 +154,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvals, approvals,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
selfApprovals
}, },
tx tx
); );
@@ -216,7 +218,8 @@ export const accessApprovalPolicyServiceFactory = ({
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
approvals, approvals,
enforcementLevel enforcementLevel,
selfApprovals
}: TUpdateAccessApprovalPolicy) => { }: TUpdateAccessApprovalPolicy) => {
const groupApprovers = approvers const groupApprovers = approvers
.filter((approver) => approver.type === ApproverType.Group) .filter((approver) => approver.type === ApproverType.Group)
@@ -262,7 +265,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvals, approvals,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
selfApprovals
}, },
tx tx
); );
@@ -26,6 +26,7 @@ export type TCreateAccessApprovalPolicy = {
projectSlug: string; projectSlug: string;
name: string; name: string;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalPolicy = { export type TUpdateAccessApprovalPolicy = {
@@ -35,6 +36,7 @@ export type TUpdateAccessApprovalPolicy = {
secretPath?: string; secretPath?: string;
name?: string; name?: string;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
selfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TDeleteAccessApprovalPolicy = { export type TDeleteAccessApprovalPolicy = {
@@ -61,6 +61,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), 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("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
) )
@@ -119,6 +120,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approvals: doc.policyApprovals, approvals: doc.policyApprovals,
secretPath: doc.policySecretPath, secretPath: doc.policySecretPath,
enforcementLevel: doc.policyEnforcementLevel, enforcementLevel: doc.policyEnforcementLevel,
selfApprovals: doc.policySelfApprovals,
envId: doc.policyEnvId, envId: doc.policyEnvId,
deletedAt: doc.policyDeletedAt deletedAt: doc.policyDeletedAt
}, },
@@ -254,6 +256,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("slug").withSchema(TableName.Environment).as("environment"),
tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), 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("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
); );
@@ -275,6 +278,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approvals: el.policyApprovals, approvals: el.policyApprovals,
secretPath: el.policySecretPath, secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel, enforcementLevel: el.policyEnforcementLevel,
selfApprovals: el.policySelfApprovals,
deletedAt: el.policyDeletedAt deletedAt: el.policyDeletedAt
}, },
requestedByUser: { requestedByUser: {
@@ -320,6 +320,11 @@ export const accessApprovalRequestServiceFactory = ({
message: "The policy associated with this access request has been deleted." 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({ const { membership, hasRole } = await permissionService.getProjectPermission({
actor, actor,
@@ -62,7 +62,8 @@ export const secretApprovalPolicyServiceFactory = ({
projectId, projectId,
secretPath, secretPath,
environment, environment,
enforcementLevel enforcementLevel,
selfApprovals
}: TCreateSapDTO) => { }: TCreateSapDTO) => {
const groupApprovers = approvers const groupApprovers = approvers
?.filter((approver) => approver.type === ApproverType.Group) ?.filter((approver) => approver.type === ApproverType.Group)
@@ -113,7 +114,8 @@ export const secretApprovalPolicyServiceFactory = ({
approvals, approvals,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
selfApprovals
}, },
tx tx
); );
@@ -172,7 +174,8 @@ export const secretApprovalPolicyServiceFactory = ({
actorAuthMethod, actorAuthMethod,
approvals, approvals,
secretPolicyId, secretPolicyId,
enforcementLevel enforcementLevel,
selfApprovals
}: TUpdateSapDTO) => { }: TUpdateSapDTO) => {
const groupApprovers = approvers const groupApprovers = approvers
?.filter((approver) => approver.type === ApproverType.Group) ?.filter((approver) => approver.type === ApproverType.Group)
@@ -218,7 +221,8 @@ export const secretApprovalPolicyServiceFactory = ({
approvals, approvals,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
selfApprovals
}, },
tx tx
); );
@@ -10,6 +10,7 @@ export type TCreateSapDTO = {
projectId: string; projectId: string;
name: string; name: string;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateSapDTO = { export type TUpdateSapDTO = {
@@ -19,6 +20,7 @@ export type TUpdateSapDTO = {
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
name?: string; name?: string;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
selfApprovals?: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TDeleteSapDTO = { export type TDeleteSapDTO = {
@@ -112,6 +112,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"), tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"),
tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), 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("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt") tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt")
); );
@@ -150,7 +151,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
secretPath: el.policySecretPath, secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel, enforcementLevel: el.policyEnforcementLevel,
envId: el.policyEnvId, envId: el.policyEnvId,
deletedAt: el.policyDeletedAt deletedAt: el.policyDeletedAt,
selfApprovals: el.policySelfApprovals
} }
}), }),
childrenMapper: [ childrenMapper: [
@@ -336,6 +338,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
), ),
db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), 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("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"),
@@ -364,7 +367,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
name: el.policyName, name: el.policyName,
approvals: el.policyApprovals, approvals: el.policyApprovals,
secretPath: el.policySecretPath, secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel enforcementLevel: el.policyEnforcementLevel,
selfApprovals: el.policySelfApprovals
}, },
committerUser: { committerUser: {
userId: el.committerUserId, 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` `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("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
db.ref("selfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policySelfApprovals"),
db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
@@ -511,7 +516,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
name: el.policyName, name: el.policyName,
approvals: el.policyApprovals, approvals: el.policyApprovals,
secretPath: el.policySecretPath, secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel enforcementLevel: el.policyEnforcementLevel,
selfApprovals: el.policySelfApprovals
}, },
committerUser: { committerUser: {
userId: el.committerUserId, userId: el.committerUserId,
@@ -352,6 +352,11 @@ export const secretApprovalRequestServiceFactory = ({
message: "The policy associated with this secret approval request has been deleted." 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({ const { hasRole } = await permissionService.getProjectPermission({
actor: ActorType.USER, actor: ActorType.USER,
@@ -23,7 +23,8 @@ export const useCreateAccessApprovalPolicy = () => {
approvers, approvers,
name, name,
secretPath, secretPath,
enforcementLevel enforcementLevel,
selfApprovals
}) => { }) => {
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
environment, environment,
@@ -32,7 +33,8 @@ export const useCreateAccessApprovalPolicy = () => {
approvers, approvers,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
selfApprovals
}); });
return data; return data;
}, },
@@ -48,13 +50,22 @@ export const useUpdateAccessApprovalPolicy = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<object, object, TUpdateAccessPolicyDTO>({ 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}`, { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
approvals, approvals,
approvers, approvers,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
selfApprovals
}); });
return data; return data;
}, },
@@ -16,6 +16,7 @@ export type TAccessApprovalPolicy = {
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
updatedAt: Date; updatedAt: Date;
approvers?: Approver[]; approvers?: Approver[];
selfApprovals: boolean;
}; };
export enum ApproverType { export enum ApproverType {
@@ -71,6 +72,7 @@ export type TAccessApprovalRequest = {
envId: string; envId: string;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
deletedAt: Date | null; deletedAt: Date | null;
selfApprovals: boolean;
}; };
reviewers: { reviewers: {
@@ -144,6 +146,7 @@ export type TCreateAccessPolicyDTO = {
approvals?: number; approvals?: number;
secretPath?: string; secretPath?: string;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
selfApprovals: boolean;
}; };
export type TUpdateAccessPolicyDTO = { export type TUpdateAccessPolicyDTO = {
@@ -154,6 +157,7 @@ export type TUpdateAccessPolicyDTO = {
environment?: string; environment?: string;
approvals?: number; approvals?: number;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
selfApprovals: boolean;
// for invalidating list // for invalidating list
projectSlug: string; projectSlug: string;
}; };
@@ -16,7 +16,8 @@ export const useCreateSecretApprovalPolicy = () => {
approvers, approvers,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
selfApprovals
}) => { }) => {
const { data } = await apiRequest.post("/api/v1/secret-approvals", { const { data } = await apiRequest.post("/api/v1/secret-approvals", {
environment, environment,
@@ -25,7 +26,8 @@ export const useCreateSecretApprovalPolicy = () => {
approvers, approvers,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
selfApprovals
}); });
return data; return data;
}, },
@@ -41,13 +43,22 @@ export const useUpdateSecretApprovalPolicy = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<object, object, TUpdateSecretPolicyDTO>({ 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}`, { const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, {
approvals, approvals,
approvers, approvers,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
selfApprovals
}); });
return data; return data;
}, },
@@ -12,6 +12,7 @@ export type TSecretApprovalPolicy = {
approvers: Approver[]; approvers: Approver[];
updatedAt: Date; updatedAt: Date;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
}; };
export enum ApproverType { export enum ApproverType {
@@ -42,6 +43,7 @@ export type TCreateSecretPolicyDTO = {
approvers?: Approver[]; approvers?: Approver[];
approvals?: number; approvals?: number;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
selfApprovals: boolean;
}; };
export type TUpdateSecretPolicyDTO = { export type TUpdateSecretPolicyDTO = {
@@ -50,6 +52,7 @@ export type TUpdateSecretPolicyDTO = {
approvers?: Approver[]; approvers?: Approver[];
secretPath?: string | null; secretPath?: string | null;
approvals?: number; approvals?: number;
selfApprovals?: boolean;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
// for invalidating list // for invalidating list
workspaceId: string; workspaceId: string;
@@ -152,7 +152,7 @@ export const AccessApprovalRequest = ({
const isAccepted = request.isApproved; const isAccepted = request.isApproved;
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const isRequestedByCurrentUser = request.requestedByUserId === user.id; const isRequestedByCurrentUser = request.requestedByUserId === user.id;
const isSelfApproveAllowed = request.policy.selfApprovals;
const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status; const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status;
let displayData: { label: string; type: "primary" | "danger" | "success" } = { let displayData: { label: string; type: "primary" | "danger" | "success" } = {
@@ -189,7 +189,8 @@ export const AccessApprovalRequest = ({
userReviewStatus, userReviewStatus,
isAccepted, isAccepted,
isSoftEnforcement, isSoftEnforcement,
isRequestedByCurrentUser isRequestedByCurrentUser,
isSelfApproveAllowed
}; };
}; };
@@ -342,15 +343,16 @@ export const AccessApprovalRequest = ({
tabIndex={0} tabIndex={0}
onClick={() => { onClick={() => {
if ( if (
(!details.isApprover || ((!details.isApprover ||
details.isReviewedByUser || details.isReviewedByUser ||
details.isRejectedByAnyone || details.isRejectedByAnyone ||
details.isAccepted) && details.isAccepted) &&
!( !(
details.isSoftEnforcement && details.isSoftEnforcement &&
details.isRequestedByCurrentUser && details.isRequestedByCurrentUser &&
!details.isAccepted !details.isAccepted
) )) ||
(request.requestedByUserId === user.id && !details.isSelfApproveAllowed)
) )
return; return;
if (membersGroupById?.[request.requestedByUserId].user) { if (membersGroupById?.[request.requestedByUserId].user) {
@@ -12,7 +12,8 @@ import {
Modal, Modal,
ModalContent, ModalContent,
Select, Select,
SelectItem SelectItem,
Switch
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { getMemberLabel } from "@app/helpers/members"; import { getMemberLabel } from "@app/helpers/members";
@@ -54,7 +55,8 @@ const formSchema = z
.array() .array()
.default([]), .default([]),
policyType: z.nativeEnum(PolicyType), policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel) enforcementLevel: z.nativeEnum(EnforcementLevel),
selfApprovals: z.boolean().default(true)
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (!(data.groupApprovers.length || data.userApprovers.length)) { if (!(data.groupApprovers.length || data.userApprovers.length)) {
@@ -101,7 +103,8 @@ export const AccessPolicyForm = ({
editValues?.approvers editValues?.approvers
?.filter((approver) => approver.type === ApproverType.Group) ?.filter((approver) => approver.type === ApproverType.Group)
.map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [], .map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [],
approvals: editValues?.approvals approvals: editValues?.approvals,
selfApprovals: editValues?.selfApprovals
} }
: undefined : undefined
}); });
@@ -441,6 +444,27 @@ export const AccessPolicyForm = ({
</FormControl> </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"> <div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}> <Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save Save
@@ -127,7 +127,9 @@ export const SecretApprovalRequestChanges = ({
} = useForm<TReviewFormSchema>({ } = useForm<TReviewFormSchema>({
resolver: zodResolver(reviewFormSchema) resolver: zodResolver(reviewFormSchema)
}); });
const shouldBlockSelfReview =
secretApprovalRequestDetails?.policy?.selfApprovals === false &&
secretApprovalRequestDetails?.committerUserId === userSession.id;
const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus; const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus;
const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus; const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus;
@@ -245,117 +247,119 @@ export const SecretApprovalRequestChanges = ({
</div> </div>
</div> </div>
</div> </div>
{!hasMerged && secretApprovalRequestDetails.status === "open" && ( {!hasMerged &&
<DropdownMenu secretApprovalRequestDetails.status === "open" &&
open={popUp.reviewChanges.isOpen} !shouldBlockSelfReview && (
onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)} <DropdownMenu
> open={popUp.reviewChanges.isOpen}
<DropdownMenuTrigger asChild> onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)}
<Button >
variant="outline_bg" <DropdownMenuTrigger asChild>
rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />} <Button
> variant="outline_bg"
Review rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}
</Button> >
</DropdownMenuTrigger> Review
<DropdownMenuContent align="end" asChild className="mt-3"> </Button>
<form onSubmit={handleSubmit(handleSubmitReview)}> </DropdownMenuTrigger>
<div className="flex w-[400px] flex-col space-y-2 p-5"> <DropdownMenuContent align="end" asChild className="mt-3">
<div className="text-lg font-medium">Finish your review</div> <form onSubmit={handleSubmit(handleSubmitReview)}>
<Controller <div className="flex w-[400px] flex-col space-y-2 p-5">
control={control} <div className="text-lg font-medium">Finish your review</div>
name="comment" <Controller
render={({ field, fieldState: { error } }) => ( control={control}
<FormControl errorText={error?.message} isError={Boolean(error)}> name="comment"
<TextArea render={({ field, fieldState: { error } }) => (
{...field} <FormControl errorText={error?.message} isError={Boolean(error)}>
placeholder="Leave a comment..." <TextArea
reSize="none" {...field}
className="text-md mt-2 h-48 border border-mineshaft-600 bg-bunker-800" placeholder="Leave a comment..."
/> reSize="none"
</FormControl> className="text-md mt-2 h-48 border border-mineshaft-600 bg-bunker-800"
)} />
/> </FormControl>
<Controller )}
control={control} />
name="status" <Controller
defaultValue={ApprovalStatus.APPROVED} control={control}
render={({ field, fieldState: { error } }) => ( name="status"
<FormControl errorText={error?.message} isError={Boolean(error)}> defaultValue={ApprovalStatus.APPROVED}
<RadioGroup render={({ field, fieldState: { error } }) => (
value={field.value} <FormControl errorText={error?.message} isError={Boolean(error)}>
onValueChange={field.onChange} <RadioGroup
className="mb-4 space-y-2" value={field.value}
aria-label="Status" onValueChange={field.onChange}
> className="mb-4 space-y-2"
<div className="flex items-center gap-2"> aria-label="Status"
<RadioGroupItem >
id="approve" <div className="flex items-center gap-2">
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500" <RadioGroupItem
value={ApprovalStatus.APPROVED} id="approve"
aria-labelledby="approve-label" className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
> value={ApprovalStatus.APPROVED}
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" /> aria-labelledby="approve-label"
</RadioGroupItem> >
<span <RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
id="approve-label" </RadioGroupItem>
className="cursor-pointer" <span
onClick={() => field.onChange(ApprovalStatus.APPROVED)} id="approve-label"
onKeyDown={(e) => { className="cursor-pointer"
if (e.key === "Enter" || e.key === " ") { onClick={() => field.onChange(ApprovalStatus.APPROVED)}
e.preventDefault(); onKeyDown={(e) => {
field.onChange(ApprovalStatus.APPROVED); if (e.key === "Enter" || e.key === " ") {
} e.preventDefault();
}} field.onChange(ApprovalStatus.APPROVED);
tabIndex={0} }
role="button" }}
> tabIndex={0}
Approve role="button"
</span> >
</div> Approve
<div className="flex items-center gap-2"> </span>
<RadioGroupItem </div>
id="reject" <div className="flex items-center gap-2">
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500" <RadioGroupItem
value={ApprovalStatus.REJECTED} id="reject"
aria-labelledby="reject-label" className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
> value={ApprovalStatus.REJECTED}
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" /> aria-labelledby="reject-label"
</RadioGroupItem> >
<span <RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
id="reject-label" </RadioGroupItem>
className="cursor-pointer" <span
onClick={() => field.onChange(ApprovalStatus.REJECTED)} id="reject-label"
onKeyDown={(e) => { className="cursor-pointer"
if (e.key === "Enter" || e.key === " ") { onClick={() => field.onChange(ApprovalStatus.REJECTED)}
e.preventDefault(); onKeyDown={(e) => {
field.onChange(ApprovalStatus.REJECTED); if (e.key === "Enter" || e.key === " ") {
} e.preventDefault();
}} field.onChange(ApprovalStatus.REJECTED);
tabIndex={0} }
role="button" }}
> tabIndex={0}
Reject role="button"
</span> >
</div> Reject
</RadioGroup> </span>
</FormControl> </div>
)} </RadioGroup>
/> </FormControl>
<div className="flex justify-end"> )}
<Button />
type="submit" <div className="flex justify-end">
isLoading={isApproving || isRejecting || isSubmitting} <Button
variant="outline_bg" type="submit"
> isLoading={isApproving || isRejecting || isSubmitting}
Submit Review variant="outline_bg"
</Button> >
Submit Review
</Button>
</div>
</div> </div>
</div> </form>
</form> </DropdownMenuContent>
</DropdownMenuContent> </DropdownMenu>
</DropdownMenu> )}
)}
</div> </div>
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
{secretApprovalRequestDetails.commits.map( {secretApprovalRequestDetails.commits.map(
@@ -422,40 +426,45 @@ export const SecretApprovalRequestChanges = ({
<div className="sticky top-0 w-1/5 pt-4" style={{ minWidth: "240px" }}> <div className="sticky top-0 w-1/5 pt-4" style={{ minWidth: "240px" }}>
<div className="text-sm text-bunker-300">Reviewers</div> <div className="text-sm text-bunker-300">Reviewers</div>
<div className="mt-2 flex flex-col space-y-2 text-sm"> <div className="mt-2 flex flex-col space-y-2 text-sm">
{secretApprovalRequestDetails?.policy?.approvers.map((requiredApprover) => { {secretApprovalRequestDetails?.policy?.approvers
const reviewer = reviewedUsers?.[requiredApprover.userId]; .filter(
return ( (requiredApprover) =>
<div !(shouldBlockSelfReview && requiredApprover.userId === userSession.id)
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1" )
key={`required-approver-${requiredApprover.userId}`} .map((requiredApprover) => {
> const reviewer = reviewedUsers?.[requiredApprover.userId];
<div className="flex-grow text-sm"> return (
<Tooltip <div
content={`${requiredApprover.firstName || ""} ${ className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
requiredApprover.lastName || "" key={`required-approver-${requiredApprover.userId}`}
}`} >
> <div className="flex-grow text-sm">
<span>{requiredApprover?.email} </span> <Tooltip
</Tooltip> content={`${requiredApprover.firstName || ""} ${
<span className="text-red">*</span> requiredApprover.lastName || ""
</div> }`}
<div> >
{reviewer?.comment && ( <span>{requiredApprover?.email} </span>
<Tooltip content={reviewer.comment}>
<FontAwesomeIcon
icon={faComment}
size="xs"
className="mr-1 text-mineshaft-300"
/>
</Tooltip> </Tooltip>
)} <span className="text-red">*</span>
<Tooltip content={reviewer?.status || ApprovalStatus.PENDING}> </div>
{getReviewedStatusSymbol(reviewer?.status)} <div>
</Tooltip> {reviewer?.comment && (
<Tooltip content={reviewer.comment}>
<FontAwesomeIcon
icon={faComment}
size="xs"
className="mr-1 text-mineshaft-300"
/>
</Tooltip>
)}
<Tooltip content={reviewer?.status || ApprovalStatus.PENDING}>
{getReviewedStatusSymbol(reviewer?.status)}
</Tooltip>
</div>
</div> </div>
</div> );
); })}
})}
{secretApprovalRequestDetails?.reviewers {secretApprovalRequestDetails?.reviewers
.filter( .filter(
(reviewer) => (reviewer) =>