diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index a9bde9971..c2bb1b94d 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -16,6 +16,7 @@ import * as workspaceController from "./workspaceController"; import * as secretScanningController from "./secretScanningController"; import * as webhookController from "./webhookController"; import * as secretImpsController from "./secretImpsController"; +import * as secretApprovalPolicyController from "./secretApprovalPolicyController"; export { authController, @@ -35,5 +36,6 @@ export { workspaceController, secretScanningController, webhookController, - secretImpsController + secretImpsController, + secretApprovalPolicyController }; diff --git a/backend/src/controllers/v1/secretApprovalPolicyController.ts b/backend/src/controllers/v1/secretApprovalPolicyController.ts new file mode 100644 index 000000000..245932353 --- /dev/null +++ b/backend/src/controllers/v1/secretApprovalPolicyController.ts @@ -0,0 +1,109 @@ +import { ForbiddenError } from "@casl/ability"; +import { Request, Response } from "express"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { validateRequest } from "../../helpers/validation"; +import { SecretApprovalPolicy } from "../../models/secretApprovalPolicy"; +import { BadRequestError } from "../../utils/errors"; +import * as reqValidator from "../../validation/secretApproval"; + +const ERR_SECRET_APPROVAL_NOT_FOUND = BadRequestError({ message: "secret approval not found" }); + +export const createSecretApprovalPolicy = async (req: Request, res: Response) => { + const { + body: { approvals, secretPath, approvers, environment, workspaceId } + } = await validateRequest(reqValidator.CreateSecretApprovalRule, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SecretApproval + ); + + const secretApproval = new SecretApprovalPolicy({ + workspace: workspaceId, + secretPath, + environment, + approvals, + approvers + }); + await secretApproval.save(); + + return res.send({ + approval: secretApproval + }); +}; + +export const updateSecretApprovalPolicy = async (req: Request, res: Response) => { + const { + body: { approvals, approvers, secretPath }, + params: { id } + } = await validateRequest(reqValidator.UpdateSecretApprovalRule, req); + + const secretApproval = await SecretApprovalPolicy.findById(id); + if (!secretApproval) throw ERR_SECRET_APPROVAL_NOT_FOUND; + + const { permission } = await getUserProjectPermissions( + req.user._id, + secretApproval.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.SecretApproval + ); + + const updatedDoc = await SecretApprovalPolicy.findByIdAndUpdate(id, { + approvals, + approvers, + ...(secretPath === null ? { $unset: { secretPath: 1 } } : { secretPath }) + }); + + return res.send({ + approval: updatedDoc + }); +}; + +export const deleteSecretApprovalPolicy = async (req: Request, res: Response) => { + const { + params: { id } + } = await validateRequest(reqValidator.DeleteSecretApprovalRule, req); + + const secretApproval = await SecretApprovalPolicy.findById(id); + if (!secretApproval) throw ERR_SECRET_APPROVAL_NOT_FOUND; + + const { permission } = await getUserProjectPermissions( + req.user._id, + secretApproval.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SecretApproval + ); + + const deletedDoc = await SecretApprovalPolicy.findByIdAndDelete(id); + + return res.send({ + approval: deletedDoc + }); +}; + +export const getSecretApprovalPolicy = async (req: Request, res: Response) => { + const { + query: { workspaceId } + } = await validateRequest(reqValidator.GetSecretApprovalRuleList, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SecretApproval + ); + + const doc = await SecretApprovalPolicy.find({ workspace: workspaceId }); + + return res.send({ + approvals: doc + }); +}; diff --git a/backend/src/ee/services/ProjectRoleService.ts b/backend/src/ee/services/ProjectRoleService.ts index 51f4a26f1..867edb7e5 100644 --- a/backend/src/ee/services/ProjectRoleService.ts +++ b/backend/src/ee/services/ProjectRoleService.ts @@ -49,7 +49,8 @@ export enum ProjectPermissionSub { IpAllowList = "ip-allowlist", Workspace = "workspace", Secrets = "secrets", - SecretRollback = "secret-rollback" + SecretRollback = "secret-rollback", + SecretApproval = "secret-approval" } type SubjectFields = { @@ -72,6 +73,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] + | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] @@ -85,6 +87,11 @@ const buildAdminPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); @@ -154,6 +161,8 @@ const buildMemberPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); @@ -203,6 +212,7 @@ const buildViewerPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); diff --git a/backend/src/index.ts b/backend/src/index.ts index aa4440a55..2a5159738 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -38,6 +38,7 @@ import { membership as v1MembershipRouter, organization as v1OrganizationRouter, password as v1PasswordRouter, + secretApprovalPolicy as v1SecretApprovalPolicy, secretImps as v1SecretImpsRouter, secret as v1SecretRouter, secretsFolder as v1SecretsFolder, @@ -176,6 +177,7 @@ const main = async () => { app.use("/api/v1/webhooks", v1WebhooksRouter); app.use("/api/v1/secret-imports", v1SecretImpsRouter); app.use("/api/v1/roles", v1RoleRouter); + app.use("/api/v1/secret-approvals", v1SecretApprovalPolicy); // v2 routes (improvements) app.use("/api/v2/signup", v2SignupRouter); diff --git a/backend/src/models/secretApprovalPolicy.ts b/backend/src/models/secretApprovalPolicy.ts new file mode 100644 index 000000000..9471f82db --- /dev/null +++ b/backend/src/models/secretApprovalPolicy.ts @@ -0,0 +1,47 @@ +import { Schema, Types, model } from "mongoose"; + +export interface ISecretApprovalPolicy { + _id: Types.ObjectId; + workspace: Types.ObjectId; + environment: string; + secretPath?: string; + approvers: Types.ObjectId[]; + approvals: number; +} + +const secretApprovalPolicySchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + approvers: [ + { + // user associated with the personal secret + type: Schema.Types.ObjectId, + ref: "Membership" + } + ], + environment: { + type: String, + required: true + }, + secretPath: { + type: String, + required: false + }, + approvals: { + type: Number, + default: 1 + } + }, + { + timestamps: true + } +); + +export const SecretApprovalPolicy = model( + "SecretApprovalPolicy", + secretApprovalPolicySchema +); diff --git a/backend/src/models/secretApprovalRequest.ts b/backend/src/models/secretApprovalRequest.ts index 910ca9135..a145b5773 100644 --- a/backend/src/models/secretApprovalRequest.ts +++ b/backend/src/models/secretApprovalRequest.ts @@ -1,81 +1,68 @@ -import mongoose, { Schema, model } from "mongoose"; -import { ISecret, Secret } from "./secret"; +import { Schema, Types, model } from "mongoose"; +import { ISecretVersion, SecretVersion } from "../ee/models/secretVersion"; -interface ISecretApprovalRequest { - secret: mongoose.Types.ObjectId; - requestedChanges: ISecret; - requestedBy: mongoose.Types.ObjectId; - approvers: IApprover[]; - status: ApprovalStatus; - timestamp: Date; - requestType: RequestType; - requestId: string; +enum ApprovalStatus { + PENDING = "pending", + APPROVED = "approved", + REJECTED = "rejected" } -interface IApprover { - userId: mongoose.Types.ObjectId; - status: ApprovalStatus; +enum CommitType { + DELETE = "delete", + UPDATE = "update", + CREATE = "create" } -export enum ApprovalStatus { - PENDING = "pending", - APPROVED = "approved", - REJECTED = "rejected" +export interface ISecretApprovalRequest { + _id: Types.ObjectId; + committer: Types.ObjectId; + approvers: { + member: Types.ObjectId; + status: ApprovalStatus; + }[]; + approvals: number; + hasMerged: boolean; + status: ApprovalStatus; + commits: { + secretVersion: Types.ObjectId; + newVersion: ISecretVersion; + op: CommitType; + }[]; } -export enum RequestType { - UPDATE = "update", - DELETE = "delete", - CREATE = "create" -} - -const approverSchema = new mongoose.Schema({ - user: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, - }, - status: { - type: String, - enum: [ApprovalStatus], - default: ApprovalStatus.PENDING, - }, -}); - const secretApprovalRequestSchema = new Schema( - { - secret: { - type: mongoose.Schema.Types.ObjectId, - ref: "Secret", - }, - requestedChanges: Secret, - requestedBy: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - }, - approvers: [approverSchema], - status: { - type: String, - enum: ApprovalStatus, - default: ApprovalStatus.PENDING, - }, - timestamp: { - type: Date, - default: Date.now, - }, - requestType: { - type: String, - enum: RequestType, - required: true, - }, - requestId: { - type: String, - required: false, - }, - }, - { - timestamps: true, - } + { + approvers: [ + { + member: { + // user associated with the personal secret + type: Schema.Types.ObjectId, + ref: "Membership" + }, + status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING } + } + ], + approvals: { + type: Number, + required: true + }, + hasMerged: { type: Boolean, default: false }, + status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING }, + committer: { type: Schema.Types.ObjectId, ref: "Membership" }, + commits: [ + { + secretVersion: { type: Types.ObjectId, ref: "SecretVersion" }, + newVersion: SecretVersion, + op: { type: String, enum: [CommitType], required: true } + } + ] + }, + { + timestamps: true + } ); -export const SecretApprovalRequest = model("SecretApprovalRequest", secretApprovalRequestSchema); \ No newline at end of file +export const SecretApprovalRequest = model( + "SecretApprovalRequest", + secretApprovalRequestSchema +); diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 46e74d58c..cfdccdc92 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -17,6 +17,7 @@ import integrationAuth from "./integrationAuth"; import secretsFolder from "./secretsFolder"; import webhooks from "./webhook"; import secretImps from "./secretImps"; +import secretApprovalPolicy from "./secretApprovalPolicy"; export { signup, @@ -37,5 +38,6 @@ export { integrationAuth, secretsFolder, webhooks, - secretImps + secretImps, + secretApprovalPolicy }; diff --git a/backend/src/routes/v1/secretApprovalPolicy.ts b/backend/src/routes/v1/secretApprovalPolicy.ts new file mode 100644 index 000000000..51c2aa3e7 --- /dev/null +++ b/backend/src/routes/v1/secretApprovalPolicy.ts @@ -0,0 +1,39 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../middleware"; +import { secretApprovalPolicyController } from "../../controllers/v1"; +import { AuthMode } from "../../variables"; + +router.get( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalPolicyController.getSecretApprovalPolicy +); + +router.post( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalPolicyController.createSecretApprovalPolicy +); + +router.patch( + "/:id", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalPolicyController.updateSecretApprovalPolicy +); + +router.delete( + "/:id", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalPolicyController.deleteSecretApprovalPolicy +); + +export default router; diff --git a/backend/src/validation/index.ts b/backend/src/validation/index.ts index 409b563c6..85899a535 100644 --- a/backend/src/validation/index.ts +++ b/backend/src/validation/index.ts @@ -1,3 +1,4 @@ +export * from "./secretApproval"; export * from "./user"; export * from "./workspace"; export * from "./bot"; diff --git a/backend/src/validation/secretApproval.ts b/backend/src/validation/secretApproval.ts new file mode 100644 index 000000000..9d4b10ce5 --- /dev/null +++ b/backend/src/validation/secretApproval.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export const GetSecretApprovalRuleList = z.object({ + query: z.object({ + workspaceId: z.string() + }) +}); + +export const CreateSecretApprovalRule = z.object({ + body: z.object({ + workspaceId: z.string(), + environment: z.string(), + secretPath: z.string().optional().nullable(), + approvers: z.string().array().optional(), + approvals: z.number().min(1).default(1) + }) +}); + +export const UpdateSecretApprovalRule = z.object({ + params: z.object({ + id: z.string() + }), + body: z.object({ + approvers: z.string().array().optional(), + approvals: z.number().min(1).optional(), + secretPath: z.string().optional().nullable() + }) +}); + +export const DeleteSecretApprovalRule = z.object({ + params: z.object({ + id: z.string() + }) +}); diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index f989f2f3a..9900176e1 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -107,6 +107,11 @@ } } }, + "approval": { + "title": "Admin Panel", + "og-title": "Manage your secret change management", + "og-description": "Infisical a simple end-to-end encrypted platform that enables teams to sync and manage their .env files." + }, "integrations": { "title": "Project Integrations", "description": "Manage your integrations of Infisical with third-party services.", diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index 200ceefb2..97e1f3d3c 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -22,14 +22,14 @@ export const ModalContent = forwardRef( ) => ( diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index f3a7ab4ca..05de5a8d5 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -7,6 +7,7 @@ export * from "./integrations"; export * from "./keys"; export * from "./organization"; export * from "./roles"; +export * from "./secretApproval"; export * from "./secretFolders"; export * from "./secretImports"; export * from "./secrets"; diff --git a/frontend/src/hooks/api/secretApproval/index.tsx b/frontend/src/hooks/api/secretApproval/index.tsx new file mode 100644 index 000000000..1d4353d9e --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/index.tsx @@ -0,0 +1,6 @@ +export { + useCreateSecretApprovalPolicy, + useDeleteSecretApprovalPolicy, + useUpdateSecretApprovalPolicy +} from "./mutation"; +export { useGetSecretApprovalPolicies } from "./queries"; diff --git a/frontend/src/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx new file mode 100644 index 000000000..f171636a0 --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/mutation.tsx @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { secretApprovalKeys } from "./queries"; +import { TCreateSecretPolicyDTO, TDeleteSecretPolicyDTO, TUpdateSecretPolicyDTO } from "./types"; + +export const useCreateSecretApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateSecretPolicyDTO>({ + mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath }) => { + const { data } = await apiRequest.post("/api/v1/secret-approvals", { + environment, + workspaceId, + approvals, + approvers, + secretPath + }); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretApprovalKeys.getApprovalPolicies(workspaceId)); + } + }); +}; + +export const useUpdateSecretApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSecretPolicyDTO>({ + mutationFn: async ({ id, approvers, approvals, secretPath }) => { + const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { + approvals, + approvers, + secretPath + }); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretApprovalKeys.getApprovalPolicies(workspaceId)); + } + }); +}; + +export const useDeleteSecretApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSecretPolicyDTO>({ + mutationFn: async ({ id }) => { + const { data } = await apiRequest.delete(`/api/v1/secret-approvals/${id}`); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretApprovalKeys.getApprovalPolicies(workspaceId)); + } + }); +}; diff --git a/frontend/src/hooks/api/secretApproval/queries.tsx b/frontend/src/hooks/api/secretApproval/queries.tsx new file mode 100644 index 000000000..6c176a27a --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/queries.tsx @@ -0,0 +1,36 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSecretApprovalPolicy } from "./types"; + +export const secretApprovalKeys = { + getApprovalPolicies: (workspaceId: string) => + [{ workspaceId }, "secret-approval-policies"] as const +}; + +const fetchApprovalPolicies = async (workspaceId: string) => { + const { data } = await apiRequest.get<{ approvals: TSecretApprovalPolicy[] }>( + "/api/v1/secret-approvals", + { params: { workspaceId } } + ); + return data.approvals; +}; + +export const useGetSecretApprovalPolicies = ({ + workspaceId, + options = {} +}: { workspaceId: string } & { + options?: UseQueryOptions< + TSecretApprovalPolicy[], + unknown, + TSecretApprovalPolicy[], + ReturnType + >; +}) => + useQuery({ + queryKey: secretApprovalKeys.getApprovalPolicies(workspaceId), + queryFn: () => fetchApprovalPolicies(workspaceId), + ...options, + enabled: Boolean(workspaceId) && (options?.enabled ?? true) + }); diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts new file mode 100644 index 000000000..8eb486664 --- /dev/null +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -0,0 +1,31 @@ +export type TSecretApprovalPolicy = { + _id: string; + workspace: string; + environment: string; + secretPath?: string; + approvers: string[]; + approvals: number; +}; + +export type TCreateSecretPolicyDTO = { + workspaceId: string; + environment: string; + secretPath?: string | null; + approvers?: string[]; + approvals?: number; +}; + +export type TUpdateSecretPolicyDTO = { + id: string; + approvers?: string[]; + secretPath?: string | null; + approvals?: number; + // for invalidating list + workspaceId: string; +}; + +export type TDeleteSecretPolicyDTO = { + id: string; + // for invalidating list + workspaceId: string; +}; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index 098078090..c91fb551d 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -4,6 +4,7 @@ export type { IntegrationAuth } from "./integrationAuth/types"; export type { TCloudIntegration, TIntegration } from "./integrations/types"; export type { UserWsKeyPair } from "./keys/types"; export type { Organization } from "./organization/types"; +export type { TSecretApprovalPolicy } from "./secretApproval/types"; export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types"; export type { SubscriptionPlan } from "./subscriptions/types"; export type { WsTag } from "./tags/types"; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 05f266f49..8dd5f12ab 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -475,6 +475,20 @@ export const AppLayout = ({ children }: LayoutProps) => { + {process.env.NEXT_PUBLIC_SECRET_APPROVAL === "true" && ( + + + + Admin Panel + + + + )} { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("approval.title") })} + + + + + +
+ +
+ + ); +}; + +export default SecretApproval; + +SecretApproval.requireAuth = true; diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx new file mode 100644 index 000000000..35243e75f --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -0,0 +1,31 @@ +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; + +import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; + +enum TabSection { + ApprovalRequests = "approval-requests", + Rules = "approval-rules" +} + +export const SecretApprovalPage = () => { + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + + return ( +
+
+

Admin Panels

+
+ + + Secret PRs + Policies + + + + + +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx new file mode 100644 index 000000000..700b1a785 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx @@ -0,0 +1,135 @@ +import { faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { usePopUp } from "@app/hooks"; +import { + useDeleteSecretApprovalPolicy, + useGetSecretApprovalPolicies, + useGetWorkspaceUsers +} from "@app/hooks/api"; +import { TSecretApprovalPolicy } from "@app/hooks/api/types"; + +import { SecretApprovalPolicyRow } from "./components/SecretApprovalPolicyRow"; +import { SecretPolicyForm } from "./components/SecretPolicyForm"; + +type Props = { + workspaceId: string; +}; + +export const SecretApprovalPolicyList = ({ workspaceId }: Props) => { + const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([ + "secretPolicyForm", + "deletePolicy" + ] as const); + const { createNotification } = useNotificationContext(); + + const { data: members } = useGetWorkspaceUsers(workspaceId); + const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({ + workspaceId + }); + + const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); + + const handleDeletePolicy = async () => { + const { _id: id } = popUp.deletePolicy.data as TSecretApprovalPolicy; + try { + await deleteSecretApprovalPolicy({ + workspaceId, + id + }); + createNotification({ + type: "success", + text: "Successfully deleted policy" + }); + handlePopUpClose("deletePolicy"); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete policy" + }); + } + }; + + return ( +
+
+
+ Approval Policies +
+ Implement policies to prevent unauthorized secret changes. +
+
+
+ +
+
+ + + + + + + + + + + + {isPoliciesLoading && ( + + )} + {!isPoliciesLoading && !policies?.length && ( + + )} + {policies?.map((policy) => ( + handlePopUpOpen("secretPolicyForm", policy)} + onDelete={() => handlePopUpOpen("deletePolicy", policy)} + /> + ))} + +
EnvironmentSecret PathEligible ApproversApproval Required +
+ +
+
+ handlePopUpToggle("secretPolicyForm", isOpen)} + members={members} + editValues={popUp.secretPolicyForm.data as TSecretApprovalPolicy} + /> + handlePopUpToggle("deletePolicy", isOpen)} + onDeleteApproved={handleDeletePolicy} + /> +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx new file mode 100644 index 000000000..c39ba2b69 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + IconButton, + Input, + Td, + Tooltip, + Tr +} from "@app/components/v2"; +import { useUpdateSecretApprovalPolicy } from "@app/hooks/api"; +import { TSecretApprovalPolicy } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + policy: TSecretApprovalPolicy; + members?: TWorkspaceUser[]; + workspaceId: string; + onEdit: () => void; + onDelete: () => void; +}; + +export const SecretApprovalPolicyRow = ({ + policy, + members = [], + workspaceId, + onEdit, + onDelete +}: Props) => { + const [selectedApprovers, setSelectedApprovers] = useState([]); + const { mutate: updateSecretApprovalPolicy, isLoading } = useUpdateSecretApprovalPolicy(); + + return ( + + {policy.environment} + {policy.secretPath || "*"} + + { + if (!isOpen) { + updateSecretApprovalPolicy( + { + workspaceId, + id: policy._id, + approvers: selectedApprovers + }, + { + onSettled: () => { + setSelectedApprovers([]); + } + } + ); + } else { + setSelectedApprovers(policy.approvers); + } + }} + > + + + + + Select members that must approve changes + {members?.map(({ _id, user }) => { + const isChecked = selectedApprovers.includes(_id); + return ( + { + evt.preventDefault(); + setSelectedApprovers((state) => + isChecked ? state.filter((el) => el !== _id) : [...state, _id] + ); + }} + key={`create-policy-members-${_id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + {policy.approvals} + +
+ + + + + + + + + + +
+ + + ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx new file mode 100644 index 000000000..0b8048c53 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx @@ -0,0 +1,243 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; +import { TSecretApprovalPolicy } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + workspaceId: string; + editValues?: TSecretApprovalPolicy; +}; + +const formSchema = z.object({ + environment: z.string(), + secretPath: z.string().optional().nullable(), + approvals: z.number().min(1), + approvers: z.string().array().optional() +}); + +type TFormSchema = z.infer; + +export const SecretPolicyForm = ({ + isOpen, + onToggle, + members = [], + workspaceId, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues + }); + const { currentWorkspace } = useWorkspace(); + const { createNotification } = useNotificationContext(); + + const environments = currentWorkspace?.environments || []; + useEffect(() => { + if (!isOpen) reset({}); + }, [isOpen]); + + const isEditMode = Boolean(editValues); + + const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy(); + const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy(); + + const handleCreatePolicy = async (data: TFormSchema) => { + try { + await createSecretApprovalPolicy({ + ...data, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully created policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create policy" + }); + } + }; + + const handleUpdatePolicy = async (data: TFormSchema) => { + if (!editValues?._id) return; + try { + await updateSecretApprovalPolicy({ + id: editValues?._id, + ...data, + secretPath: data.secretPath || null, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "failed to update policy" + }); + } + }; + + const handleFormSubmit = async (data: TFormSchema) => { + if (isEditMode) { + await handleUpdatePolicy(data); + } else { + await handleCreatePolicy(data); + } + }; + + return ( + + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + + + + Select members that must approve changes + {members.map(({ _id, user }) => { + const isChecked = value?.includes(_id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el) => el !== _id) + : [...(value || []), _id] + ); + }} + key={`create-policy-members-${_id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx new file mode 100644 index 000000000..f204264b4 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx @@ -0,0 +1 @@ +export { SecretApprovalPolicyList } from "./SecretApprovalPolicyList"; diff --git a/frontend/src/views/SecretApprovalPage/index.tsx b/frontend/src/views/SecretApprovalPage/index.tsx new file mode 100644 index 000000000..e45406427 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/index.tsx @@ -0,0 +1 @@ +export { SecretApprovalPage } from "./SecretApprovalPage";