Merge pull request #1031 from akhilmhdh/feat/secret-approval

Secret approval policies feature
This commit is contained in:
Maidul Islam
2023-09-27 23:56:16 -07:00
committed by GitHub
26 changed files with 1019 additions and 76 deletions
+3 -1
View File
@@ -16,6 +16,7 @@ import * as workspaceController from "./workspaceController";
import * as secretScanningController from "./secretScanningController"; import * as secretScanningController from "./secretScanningController";
import * as webhookController from "./webhookController"; import * as webhookController from "./webhookController";
import * as secretImpsController from "./secretImpsController"; import * as secretImpsController from "./secretImpsController";
import * as secretApprovalPolicyController from "./secretApprovalPolicyController";
export { export {
authController, authController,
@@ -35,5 +36,6 @@ export {
workspaceController, workspaceController,
secretScanningController, secretScanningController,
webhookController, webhookController,
secretImpsController secretImpsController,
secretApprovalPolicyController
}; };
@@ -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
});
};
+11 -1
View File
@@ -49,7 +49,8 @@ export enum ProjectPermissionSub {
IpAllowList = "ip-allowlist", IpAllowList = "ip-allowlist",
Workspace = "workspace", Workspace = "workspace",
Secrets = "secrets", Secrets = "secrets",
SecretRollback = "secret-rollback" SecretRollback = "secret-rollback",
SecretApproval = "secret-approval"
} }
type SubjectFields = { type SubjectFields = {
@@ -72,6 +73,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
| [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
@@ -85,6 +87,11 @@ const buildAdminPermission = () => {
can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets);
can(ProjectPermissionActions.Delete, 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.Read, ProjectPermissionSub.SecretRollback);
can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback);
@@ -154,6 +161,8 @@ const buildMemberPermission = () => {
can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets);
can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback);
@@ -203,6 +212,7 @@ const buildViewerPermission = () => {
const { can, build } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility); const { can, build } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionActions.Read, ProjectPermissionSub.Member);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); can(ProjectPermissionActions.Read, ProjectPermissionSub.Role);
+2
View File
@@ -38,6 +38,7 @@ import {
membership as v1MembershipRouter, membership as v1MembershipRouter,
organization as v1OrganizationRouter, organization as v1OrganizationRouter,
password as v1PasswordRouter, password as v1PasswordRouter,
secretApprovalPolicy as v1SecretApprovalPolicy,
secretImps as v1SecretImpsRouter, secretImps as v1SecretImpsRouter,
secret as v1SecretRouter, secret as v1SecretRouter,
secretsFolder as v1SecretsFolder, secretsFolder as v1SecretsFolder,
@@ -176,6 +177,7 @@ const main = async () => {
app.use("/api/v1/webhooks", v1WebhooksRouter); app.use("/api/v1/webhooks", v1WebhooksRouter);
app.use("/api/v1/secret-imports", v1SecretImpsRouter); app.use("/api/v1/secret-imports", v1SecretImpsRouter);
app.use("/api/v1/roles", v1RoleRouter); app.use("/api/v1/roles", v1RoleRouter);
app.use("/api/v1/secret-approvals", v1SecretApprovalPolicy);
// v2 routes (improvements) // v2 routes (improvements)
app.use("/api/v2/signup", v2SignupRouter); app.use("/api/v2/signup", v2SignupRouter);
@@ -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<ISecretApprovalPolicy>(
{
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<ISecretApprovalPolicy>(
"SecretApprovalPolicy",
secretApprovalPolicySchema
);
+58 -71
View File
@@ -1,81 +1,68 @@
import mongoose, { Schema, model } from "mongoose"; import { Schema, Types, model } from "mongoose";
import { ISecret, Secret } from "./secret"; import { ISecretVersion, SecretVersion } from "../ee/models/secretVersion";
interface ISecretApprovalRequest { enum ApprovalStatus {
secret: mongoose.Types.ObjectId; PENDING = "pending",
requestedChanges: ISecret; APPROVED = "approved",
requestedBy: mongoose.Types.ObjectId; REJECTED = "rejected"
approvers: IApprover[];
status: ApprovalStatus;
timestamp: Date;
requestType: RequestType;
requestId: string;
} }
interface IApprover { enum CommitType {
userId: mongoose.Types.ObjectId; DELETE = "delete",
status: ApprovalStatus; UPDATE = "update",
CREATE = "create"
} }
export enum ApprovalStatus { export interface ISecretApprovalRequest {
PENDING = "pending", _id: Types.ObjectId;
APPROVED = "approved", committer: Types.ObjectId;
REJECTED = "rejected" 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<ISecretApprovalRequest>( const secretApprovalRequestSchema = new Schema<ISecretApprovalRequest>(
{ {
secret: { approvers: [
type: mongoose.Schema.Types.ObjectId, {
ref: "Secret", member: {
}, // user associated with the personal secret
requestedChanges: Secret, type: Schema.Types.ObjectId,
requestedBy: { ref: "Membership"
type: mongoose.Schema.Types.ObjectId, },
ref: "User", status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING }
}, }
approvers: [approverSchema], ],
status: { approvals: {
type: String, type: Number,
enum: ApprovalStatus, required: true
default: ApprovalStatus.PENDING, },
}, hasMerged: { type: Boolean, default: false },
timestamp: { status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING },
type: Date, committer: { type: Schema.Types.ObjectId, ref: "Membership" },
default: Date.now, commits: [
}, {
requestType: { secretVersion: { type: Types.ObjectId, ref: "SecretVersion" },
type: String, newVersion: SecretVersion,
enum: RequestType, op: { type: String, enum: [CommitType], required: true }
required: true, }
}, ]
requestId: { },
type: String, {
required: false, timestamps: true
}, }
},
{
timestamps: true,
}
); );
export const SecretApprovalRequest = model<ISecretApprovalRequest>("SecretApprovalRequest", secretApprovalRequestSchema); export const SecretApprovalRequest = model<ISecretApprovalRequest>(
"SecretApprovalRequest",
secretApprovalRequestSchema
);
+3 -1
View File
@@ -17,6 +17,7 @@ import integrationAuth from "./integrationAuth";
import secretsFolder from "./secretsFolder"; import secretsFolder from "./secretsFolder";
import webhooks from "./webhook"; import webhooks from "./webhook";
import secretImps from "./secretImps"; import secretImps from "./secretImps";
import secretApprovalPolicy from "./secretApprovalPolicy";
export { export {
signup, signup,
@@ -37,5 +38,6 @@ export {
integrationAuth, integrationAuth,
secretsFolder, secretsFolder,
webhooks, webhooks,
secretImps secretImps,
secretApprovalPolicy
}; };
@@ -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;
+1
View File
@@ -1,3 +1,4 @@
export * from "./secretApproval";
export * from "./user"; export * from "./user";
export * from "./workspace"; export * from "./workspace";
export * from "./bot"; export * from "./bot";
+34
View File
@@ -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()
})
});
@@ -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": { "integrations": {
"title": "Project Integrations", "title": "Project Integrations",
"description": "Manage your integrations of Infisical with third-party services.", "description": "Manage your integrations of Infisical with third-party services.",
+2 -2
View File
@@ -22,14 +22,14 @@ export const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(
) => ( ) => (
<DialogPrimitive.Portal> <DialogPrimitive.Portal>
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
className={twMerge("fixed inset-0 z-[70] h-full w-full animate-fadeIn", overlayClassName)} className={twMerge("fixed inset-0 z-30 h-full w-full animate-fadeIn", overlayClassName)}
style={{ backgroundColor: "rgba(0, 0, 0, 0.7)" }} style={{ backgroundColor: "rgba(0, 0, 0, 0.7)" }}
/> />
<DialogPrimitive.Content {...props} ref={forwardedRef}> <DialogPrimitive.Content {...props} ref={forwardedRef}>
<Card <Card
isRounded isRounded
className={twMerge( className={twMerge(
"fixed top-1/2 left-1/2 z-[90] dark:[color-scheme:dark] max-h-screen overflow-y-auto thin-scrollbar max-w-lg -translate-y-2/4 -translate-x-2/4 animate-popIn border border-mineshaft-600 drop-shadow-2xl", "fixed top-1/2 left-1/2 z-30 dark:[color-scheme:dark] max-h-screen overflow-y-auto thin-scrollbar max-w-lg -translate-y-2/4 -translate-x-2/4 animate-popIn border border-mineshaft-600 drop-shadow-2xl",
className className
)} )}
> >
+1
View File
@@ -7,6 +7,7 @@ export * from "./integrations";
export * from "./keys"; export * from "./keys";
export * from "./organization"; export * from "./organization";
export * from "./roles"; export * from "./roles";
export * from "./secretApproval";
export * from "./secretFolders"; export * from "./secretFolders";
export * from "./secretImports"; export * from "./secretImports";
export * from "./secrets"; export * from "./secrets";
@@ -0,0 +1,6 @@
export {
useCreateSecretApprovalPolicy,
useDeleteSecretApprovalPolicy,
useUpdateSecretApprovalPolicy
} from "./mutation";
export { useGetSecretApprovalPolicies } from "./queries";
@@ -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));
}
});
};
@@ -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<typeof secretApprovalKeys.getApprovalPolicies>
>;
}) =>
useQuery({
queryKey: secretApprovalKeys.getApprovalPolicies(workspaceId),
queryFn: () => fetchApprovalPolicies(workspaceId),
...options,
enabled: Boolean(workspaceId) && (options?.enabled ?? true)
});
@@ -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;
};
+1
View File
@@ -4,6 +4,7 @@ export type { IntegrationAuth } from "./integrationAuth/types";
export type { TCloudIntegration, TIntegration } from "./integrations/types"; export type { TCloudIntegration, TIntegration } from "./integrations/types";
export type { UserWsKeyPair } from "./keys/types"; export type { UserWsKeyPair } from "./keys/types";
export type { Organization } from "./organization/types"; export type { Organization } from "./organization/types";
export type { TSecretApprovalPolicy } from "./secretApproval/types";
export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types"; export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types";
export type { SubscriptionPlan } from "./subscriptions/types"; export type { SubscriptionPlan } from "./subscriptions/types";
export type { WsTag } from "./tags/types"; export type { WsTag } from "./tags/types";
@@ -475,6 +475,20 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem> </MenuItem>
</a> </a>
</Link> </Link>
{process.env.NEXT_PUBLIC_SECRET_APPROVAL === "true" && (
<Link href={`/project/${currentWorkspace?._id}/approval`} passHref>
<a>
<MenuItem
isSelected={
router.asPath === `/project/${currentWorkspace?._id}/allowlist`
}
icon="system-outline-126-verified"
>
Admin Panel
</MenuItem>
</a>
</Link>
)}
<Link href={`/project/${currentWorkspace?._id}/allowlist`} passHref> <Link href={`/project/${currentWorkspace?._id}/allowlist`} passHref>
<a> <a>
<MenuItem <MenuItem
@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { SecretApprovalPage } from "@app/views/SecretApprovalPage";
const SecretApproval = () => {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: t("approval.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={String(t("approval.og-title"))} />
<meta name="og:description" content={String(t("approval.og-description"))} />
</Head>
<div className="h-full">
<SecretApprovalPage />
</div>
</>
);
};
export default SecretApproval;
SecretApproval.requireAuth = true;
@@ -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 (
<div className="container mx-auto bg-bunker-800 text-white w-full h-full">
<div className="my-6">
<p className="text-3xl font-semibold text-gray-200">Admin Panels</p>
</div>
<Tabs defaultValue={TabSection.ApprovalRequests}>
<TabList>
<Tab value={TabSection.ApprovalRequests}>Secret PRs</Tab>
<Tab value={TabSection.Rules}>Policies</Tab>
</TabList>
<TabPanel value={TabSection.Rules}>
<SecretApprovalPolicyList workspaceId={workspaceId} />
</TabPanel>
</Tabs>
</div>
);
};
@@ -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 (
<div>
<div className="flex justify-between mb-6">
<div className="flex flex-col">
<span className="text-xl font-semibold text-mineshaft-100">Approval Policies</span>
<div className="text-bunker-300 mt-2 text-sm">
Implement policies to prevent unauthorized secret changes.
</div>
</div>
<div>
<Button
onClick={() => handlePopUpOpen("secretPolicyForm")}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Create policy
</Button>
</div>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th>Eligible Approvers</Th>
<Th>Approval Required</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPoliciesLoading && (
<TableSkeleton columns={4} innerKey="secret-policies" className="bg-mineshaft-700" />
)}
{!isPoliciesLoading && !policies?.length && (
<Td colSpan={5}>
<EmptyState title="No policies found" icon={faFileShield} />
</Td>
)}
{policies?.map((policy) => (
<SecretApprovalPolicyRow
workspaceId={workspaceId}
policy={policy}
key={policy._id}
members={members}
onEdit={() => handlePopUpOpen("secretPolicyForm", policy)}
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
/>
))}
</TBody>
</Table>
</TableContainer>
<SecretPolicyForm
workspaceId={workspaceId}
isOpen={popUp.secretPolicyForm.isOpen}
onToggle={(isOpen) => handlePopUpToggle("secretPolicyForm", isOpen)}
members={members}
editValues={popUp.secretPolicyForm.data as TSecretApprovalPolicy}
/>
<DeleteActionModal
isOpen={popUp.deletePolicy.isOpen}
deleteKey="remove"
title="Do you want to remove this polciy?"
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
onDeleteApproved={handleDeletePolicy}
/>
</div>
);
};
@@ -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<string[]>([]);
const { mutate: updateSecretApprovalPolicy, isLoading } = useUpdateSecretApprovalPolicy();
return (
<Tr>
<Td>{policy.environment}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td>
<DropdownMenu
onOpenChange={(isOpen) => {
if (!isOpen) {
updateSecretApprovalPolicy(
{
workspaceId,
id: policy._id,
approvers: selectedApprovers
},
{
onSettled: () => {
setSelectedApprovers([]);
}
}
);
} else {
setSelectedApprovers(policy.approvers);
}
}}
>
<DropdownMenuTrigger asChild disabled={isLoading}>
<Input
isReadOnly
value={policy.approvers?.length ? `${policy.approvers.length} selected` : "None"}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>Select members that must approve changes</DropdownMenuLabel>
{members?.map(({ _id, user }) => {
const isChecked = selectedApprovers.includes(_id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
setSelectedApprovers((state) =>
isChecked ? state.filter((el) => el !== _id) : [...state, _id]
);
}}
key={`create-policy-members-${_id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.email}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</Td>
<Td>{policy.approvals}</Td>
<Td>
<div className="flex items-center justify-end space-x-4">
<Tooltip content="Edit">
<IconButton variant="plain" ariaLabel="edit" onClick={onEdit}>
<FontAwesomeIcon icon={faPencil} size="lg" />
</IconButton>
</Tooltip>
<Tooltip content="Delete">
<IconButton
variant="plain"
colorSchema="danger"
size="lg"
ariaLabel="edit"
onClick={onDelete}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
</Td>
</Tr>
);
};
@@ -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<typeof formSchema>;
export const SecretPolicyForm = ({
isOpen,
onToggle,
members = [],
workspaceId,
editValues
}: Props) => {
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TFormSchema>({
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 (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent title={isEditMode ? "Edit policy" : "Create policy"}>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
className="mt-4"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
>
{environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`azure-key-vault-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
render={({ field, fieldState: { error } }) => (
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="approvers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value?.length ? `${value.length} selected` : "None"}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>Select members that must approve changes</DropdownMenuLabel>
{members.map(({ _id, user }) => {
const isChecked = value?.includes(_id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el) => el !== _id)
: [...(value || []), _id]
);
}}
key={`create-policy-members-${_id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.email}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="number"
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
<div className="flex mt-8 space-x-4 items-center">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save
</Button>
<Button onClick={() => onToggle(false)} variant="outline_bg">
Close
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { SecretApprovalPolicyList } from "./SecretApprovalPolicyList";
@@ -0,0 +1 @@
export { SecretApprovalPage } from "./SecretApprovalPage";