mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 22:27:48 +00:00
Merge pull request #1031 from akhilmhdh/feat/secret-approval
Secret approval policies feature
This commit is contained in:
@@ -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
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
export * from "./secretApproval";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./workspace";
|
export * from "./workspace";
|
||||||
export * from "./bot";
|
export * from "./bot";
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+135
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+120
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+243
@@ -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";
|
||||||
Reference in New Issue
Block a user