mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 22:27:22 +00:00
Feat: Request access
This commit is contained in:
@@ -5,7 +5,7 @@ import knex from "knex";
|
|||||||
import { writeFileSync } from "fs";
|
import { writeFileSync } from "fs";
|
||||||
|
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
path: path.join(__dirname, "../../.env.migration")
|
path: path.join(__dirname, "../../.env")
|
||||||
});
|
});
|
||||||
|
|
||||||
const db = knex({
|
const db = knex({
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicy))) {
|
||||||
|
await knex.schema.createTable(TableName.AccessApprovalPolicy, (t) => {
|
||||||
|
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||||
|
t.string("name").notNullable();
|
||||||
|
t.integer("approvals").defaultTo(1).notNullable();
|
||||||
|
t.uuid("envId").notNullable();
|
||||||
|
t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE");
|
||||||
|
t.timestamps(true, true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicy);
|
||||||
|
|
||||||
|
if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyApprover))) {
|
||||||
|
await knex.schema.createTable(TableName.AccessApprovalPolicyApprover, (t) => {
|
||||||
|
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||||
|
t.uuid("approverId").notNullable();
|
||||||
|
t.foreign("approverId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
|
||||||
|
t.uuid("policyId").notNullable();
|
||||||
|
t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE");
|
||||||
|
t.timestamps(true, true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy);
|
||||||
|
await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover);
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy);
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Code generated by automation script, DO NOT EDIT.
|
||||||
|
// Automated by pulling database and generating zod schema
|
||||||
|
// To update. Just run npm run generate:schema
|
||||||
|
// Written by akhilmhdh.
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
|
export const AccessApprovalPoliciesApproversSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
approverId: z.string().uuid(),
|
||||||
|
policyId: z.string().uuid(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAccessApprovalPoliciesApprovers = z.infer<typeof AccessApprovalPoliciesApproversSchema>;
|
||||||
|
export type TAccessApprovalPoliciesApproversInsert = Omit<
|
||||||
|
z.input<typeof AccessApprovalPoliciesApproversSchema>,
|
||||||
|
TImmutableDBKeys
|
||||||
|
>;
|
||||||
|
export type TAccessApprovalPoliciesApproversUpdate = Partial<
|
||||||
|
Omit<z.input<typeof AccessApprovalPoliciesApproversSchema>, TImmutableDBKeys>
|
||||||
|
>;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Code generated by automation script, DO NOT EDIT.
|
||||||
|
// Automated by pulling database and generating zod schema
|
||||||
|
// To update. Just run npm run generate:schema
|
||||||
|
// Written by akhilmhdh.
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
|
export const AccessApprovalPoliciesSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string(),
|
||||||
|
approvals: z.number().default(1),
|
||||||
|
envId: z.string().uuid(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;
|
||||||
|
export type TAccessApprovalPoliciesInsert = Omit<z.input<typeof AccessApprovalPoliciesSchema>, TImmutableDBKeys>;
|
||||||
|
export type TAccessApprovalPoliciesUpdate = Partial<
|
||||||
|
Omit<z.input<typeof AccessApprovalPoliciesSchema>, TImmutableDBKeys>
|
||||||
|
>;
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
|
import { sapPubSchema } from "@app/server/routes/sanitizedSchemas";
|
||||||
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => {
|
||||||
|
server.route({
|
||||||
|
url: "/",
|
||||||
|
method: "POST",
|
||||||
|
schema: {
|
||||||
|
body: z
|
||||||
|
.object({
|
||||||
|
workspaceId: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
environment: z.string(),
|
||||||
|
approvers: z.string().array().min(1),
|
||||||
|
approvals: z.number().min(1).default(1)
|
||||||
|
})
|
||||||
|
.refine((data) => data.approvals <= data.approvers.length, {
|
||||||
|
path: ["approvals"],
|
||||||
|
message: "The number of approvals should be lower than the number of approvers."
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
approval: sapPubSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const approval = await server.services.accessApprovalPolicy.createAccessApprovalPolicy({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
projectId: req.body.workspaceId,
|
||||||
|
...req.body,
|
||||||
|
name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`
|
||||||
|
});
|
||||||
|
return { approval };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
workspaceId: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
approvals: sapPubSchema.merge(z.object({ approvers: z.string().array() })).array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const approvals = await server.services.accessApprovalPolicy.getAccessApprovalPolicyByProjectId({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
projectId: req.query.workspaceId
|
||||||
|
});
|
||||||
|
return { approvals };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:policyId",
|
||||||
|
method: "PATCH",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
policyId: z.string()
|
||||||
|
}),
|
||||||
|
body: z
|
||||||
|
.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
approvers: z.string().array().min(1),
|
||||||
|
approvals: z.number().min(1).default(1)
|
||||||
|
})
|
||||||
|
.refine((data) => data.approvals <= data.approvers.length, {
|
||||||
|
path: ["approvals"],
|
||||||
|
message: "The number of approvals should be lower than the number of approvers."
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
approval: sapPubSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
await server.services.accessApprovalPolicy.updateAccessApprovalPolicy({
|
||||||
|
policyId: req.params.policyId,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
...req.body
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:policyId",
|
||||||
|
method: "DELETE",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
policyId: z.string()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
approval: sapPubSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const approval = await server.services.accessApprovalPolicy.deleteAccessApprovalPolicy({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
policyId: req.params.policyId
|
||||||
|
});
|
||||||
|
return { approval };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { TDbClient } from "@app/db";
|
||||||
|
import { TableName } from "@app/db/schemas";
|
||||||
|
import { ormify } from "@app/lib/knex";
|
||||||
|
|
||||||
|
export type TAccessApprovalPolicyApproverDALFactory = ReturnType<typeof accessApprovalPolicyApproverDALFactory>;
|
||||||
|
|
||||||
|
export const accessApprovalPolicyApproverDALFactory = (db: TDbClient) => {
|
||||||
|
const accessApprovalPolicyApproverOrm = ormify(db, TableName.AccessApprovalPolicyApprover);
|
||||||
|
return { ...accessApprovalPolicyApproverOrm };
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TDbClient } from "@app/db";
|
||||||
|
import { TableName, TAccessApprovalPolicies } from "@app/db/schemas";
|
||||||
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
|
import { buildFindFilter, mergeOneToManyRelation, ormify, selectAllTableCols, TFindFilter } from "@app/lib/knex";
|
||||||
|
|
||||||
|
export type TAccessApprovalPolicyDALFactory = ReturnType<typeof accessApprovalPolicyDALFactory>;
|
||||||
|
|
||||||
|
export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
|
||||||
|
const accessApprovalPolicyOrm = ormify(db, TableName.AccessApprovalPolicy);
|
||||||
|
|
||||||
|
const sapFindQuery = async (tx: Knex, filter: TFindFilter<TAccessApprovalPolicies>) => {
|
||||||
|
const result = await tx(TableName.AccessApprovalPolicy)
|
||||||
|
// eslint-disable-next-line
|
||||||
|
.where(buildFindFilter(filter))
|
||||||
|
.join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
|
||||||
|
.join(
|
||||||
|
TableName.AccessApprovalPolicyApprover,
|
||||||
|
`${TableName.AccessApprovalPolicy}.id`,
|
||||||
|
`${TableName.AccessApprovalPolicyApprover}.policyId`
|
||||||
|
)
|
||||||
|
.select(tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover))
|
||||||
|
.select(tx.ref("name").withSchema(TableName.Environment).as("envName"))
|
||||||
|
.select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug"))
|
||||||
|
.select(tx.ref("id").withSchema(TableName.Environment).as("envId"))
|
||||||
|
.select(tx.ref("projectId").withSchema(TableName.Environment))
|
||||||
|
.select(selectAllTableCols(TableName.AccessApprovalPolicy));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findById = async (id: string, tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const doc = await sapFindQuery(tx || db, {
|
||||||
|
[`${TableName.AccessApprovalPolicy}.id` as "id"]: id
|
||||||
|
});
|
||||||
|
const formatedDoc = mergeOneToManyRelation(
|
||||||
|
doc,
|
||||||
|
"id",
|
||||||
|
({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({
|
||||||
|
...el,
|
||||||
|
envId,
|
||||||
|
environment: { id: envId, name, slug }
|
||||||
|
}),
|
||||||
|
({ approverId }) => approverId,
|
||||||
|
"approvers"
|
||||||
|
);
|
||||||
|
return formatedDoc?.[0];
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "FindById" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const find = async (filter: TFindFilter<TAccessApprovalPolicies & { projectId: string }>, tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const docs = await sapFindQuery(tx || db, filter);
|
||||||
|
const formatedDoc = mergeOneToManyRelation(
|
||||||
|
docs,
|
||||||
|
"id",
|
||||||
|
({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({
|
||||||
|
...el,
|
||||||
|
envId,
|
||||||
|
environment: { id: envId, name, slug }
|
||||||
|
}),
|
||||||
|
({ approverId }) => approverId,
|
||||||
|
"approvers"
|
||||||
|
);
|
||||||
|
return formatedDoc;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...accessApprovalPolicyOrm, find, findById };
|
||||||
|
};
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { ForbiddenError } from "@casl/ability";
|
||||||
|
|
||||||
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
|
||||||
|
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||||
|
|
||||||
|
import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal";
|
||||||
|
import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal";
|
||||||
|
import {
|
||||||
|
TCreateAccessApprovalPolicy,
|
||||||
|
TDeleteAccessApprovalPolicy,
|
||||||
|
TListAccessApprovalPoliciesDTO,
|
||||||
|
TUpdateAccessApprovalPolicy
|
||||||
|
} from "./access-approval-policy-types";
|
||||||
|
|
||||||
|
type TSecretApprovalPolicyServiceFactoryDep = {
|
||||||
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
|
accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory;
|
||||||
|
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||||
|
accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TAccessApprovalPolicyServiceFactory = ReturnType<typeof accessApprovalPolicyServiceFactory>;
|
||||||
|
|
||||||
|
export const accessApprovalPolicyServiceFactory = ({
|
||||||
|
accessApprovalPolicyDAL,
|
||||||
|
accessApprovalPolicyApproverDAL,
|
||||||
|
permissionService,
|
||||||
|
projectEnvDAL,
|
||||||
|
projectMembershipDAL
|
||||||
|
}: TSecretApprovalPolicyServiceFactoryDep) => {
|
||||||
|
const createAccessApprovalPolicy = async ({
|
||||||
|
name,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
approvals,
|
||||||
|
approvers,
|
||||||
|
projectId,
|
||||||
|
environment
|
||||||
|
}: TCreateAccessApprovalPolicy) => {
|
||||||
|
if (approvals > approvers.length)
|
||||||
|
throw new BadRequestError({ message: "Approvals cannot be greater than approvers" });
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Create,
|
||||||
|
ProjectPermissionSub.SecretApproval
|
||||||
|
);
|
||||||
|
const env = await projectEnvDAL.findOne({ slug: environment, projectId });
|
||||||
|
if (!env) throw new BadRequestError({ message: "Environment not found" });
|
||||||
|
|
||||||
|
const secretApprovers = await projectMembershipDAL.find({
|
||||||
|
projectId,
|
||||||
|
$in: { id: approvers }
|
||||||
|
});
|
||||||
|
if (secretApprovers.length !== approvers.length) {
|
||||||
|
throw new BadRequestError({ message: "Approver not found in project" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => {
|
||||||
|
const doc = await accessApprovalPolicyDAL.create(
|
||||||
|
{
|
||||||
|
envId: env.id,
|
||||||
|
approvals,
|
||||||
|
name
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
await accessApprovalPolicyApproverDAL.insertMany(
|
||||||
|
secretApprovers.map(({ id }) => ({
|
||||||
|
approverId: id,
|
||||||
|
policyId: doc.id
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
return { ...accessApproval, environment: env, projectId };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAccessApprovalPolicyByProjectId = async ({
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
projectId
|
||||||
|
}: TListAccessApprovalPoliciesDTO) => {
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
|
||||||
|
|
||||||
|
const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId });
|
||||||
|
return accessApprovalPolicies;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAccessApprovalPolicy = async ({
|
||||||
|
policyId,
|
||||||
|
approvers,
|
||||||
|
name,
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
approvals
|
||||||
|
}: TUpdateAccessApprovalPolicy) => {
|
||||||
|
const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId);
|
||||||
|
if (!accessApprovalPolicy) throw new BadRequestError({ message: "Secret approval policy not found" });
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
accessApprovalPolicy.projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval);
|
||||||
|
|
||||||
|
const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => {
|
||||||
|
const doc = await accessApprovalPolicyDAL.updateById(
|
||||||
|
accessApprovalPolicy.id,
|
||||||
|
{
|
||||||
|
approvals,
|
||||||
|
name
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
if (approvers) {
|
||||||
|
// Find the workspace project memberships of the users passed in the approvers array
|
||||||
|
const secretApprovers = await projectMembershipDAL.find(
|
||||||
|
{
|
||||||
|
projectId: accessApprovalPolicy.projectId,
|
||||||
|
$in: { id: approvers }
|
||||||
|
},
|
||||||
|
{ tx }
|
||||||
|
);
|
||||||
|
if (secretApprovers.length !== approvers.length)
|
||||||
|
throw new BadRequestError({ message: "Approver not found in project" });
|
||||||
|
if (doc.approvals > secretApprovers.length)
|
||||||
|
throw new BadRequestError({ message: "Approvals cannot be greater than approvers" });
|
||||||
|
await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx);
|
||||||
|
await accessApprovalPolicyApproverDAL.insertMany(
|
||||||
|
secretApprovers.map(({ id }) => ({
|
||||||
|
approverId: id,
|
||||||
|
policyId: doc.id
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...updatedPolicy,
|
||||||
|
environment: accessApprovalPolicy.environment,
|
||||||
|
projectId: accessApprovalPolicy.projectId
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAccessApprovalPolicy = async ({
|
||||||
|
policyId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
}: TDeleteAccessApprovalPolicy) => {
|
||||||
|
const policy = await accessApprovalPolicyDAL.findById(policyId);
|
||||||
|
if (!policy) throw new BadRequestError({ message: "Secret approval policy not found" });
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
policy.projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Delete,
|
||||||
|
ProjectPermissionSub.SecretApproval
|
||||||
|
);
|
||||||
|
|
||||||
|
await accessApprovalPolicyDAL.deleteById(policyId);
|
||||||
|
return policy;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createAccessApprovalPolicy,
|
||||||
|
deleteAccessApprovalPolicy,
|
||||||
|
updateAccessApprovalPolicy,
|
||||||
|
getAccessApprovalPolicyByProjectId
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
|
export type TCreateAccessApprovalPolicy = {
|
||||||
|
approvals: number;
|
||||||
|
environment: string;
|
||||||
|
approvers: string[];
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TUpdateAccessApprovalPolicy = {
|
||||||
|
policyId: string;
|
||||||
|
approvals?: number;
|
||||||
|
approvers: string[];
|
||||||
|
name?: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TDeleteAccessApprovalPolicy = {
|
||||||
|
policyId: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TListAccessApprovalPoliciesDTO = TProjectPermission;
|
||||||
|
|
||||||
|
export type TGetBoardAccessApprovalPolicy = {
|
||||||
|
projectId: string;
|
||||||
|
environment: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
@@ -2,6 +2,9 @@ import { Knex } from "knex";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { registerV1EERoutes } from "@app/ee/routes/v1";
|
import { registerV1EERoutes } from "@app/ee/routes/v1";
|
||||||
|
import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal";
|
||||||
|
import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal";
|
||||||
|
import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service";
|
||||||
import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
|
import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
|
||||||
import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue";
|
import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue";
|
||||||
import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
|
import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
|
||||||
@@ -207,6 +210,10 @@ export const registerRoutes = async (
|
|||||||
const scimDAL = scimDALFactory(db);
|
const scimDAL = scimDALFactory(db);
|
||||||
const ldapConfigDAL = ldapConfigDALFactory(db);
|
const ldapConfigDAL = ldapConfigDALFactory(db);
|
||||||
const ldapGroupMapDAL = ldapGroupMapDALFactory(db);
|
const ldapGroupMapDAL = ldapGroupMapDALFactory(db);
|
||||||
|
|
||||||
|
const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db);
|
||||||
|
const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db);
|
||||||
|
|
||||||
const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db);
|
const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db);
|
||||||
const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db);
|
const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db);
|
||||||
const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db);
|
const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db);
|
||||||
@@ -265,6 +272,15 @@ export const registerRoutes = async (
|
|||||||
secretApprovalPolicyDAL
|
secretApprovalPolicyDAL
|
||||||
});
|
});
|
||||||
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
|
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
|
||||||
|
|
||||||
|
const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({
|
||||||
|
accessApprovalPolicyDAL,
|
||||||
|
accessApprovalPolicyApproverDAL,
|
||||||
|
permissionService,
|
||||||
|
projectEnvDAL,
|
||||||
|
projectMembershipDAL
|
||||||
|
});
|
||||||
|
|
||||||
const samlService = samlConfigServiceFactory({
|
const samlService = samlConfigServiceFactory({
|
||||||
permissionService,
|
permissionService,
|
||||||
orgBotDAL,
|
orgBotDAL,
|
||||||
@@ -596,6 +612,7 @@ export const registerRoutes = async (
|
|||||||
secretVersionTagDAL,
|
secretVersionTagDAL,
|
||||||
secretQueueService
|
secretQueueService
|
||||||
});
|
});
|
||||||
|
|
||||||
const secretRotationQueue = secretRotationQueueFactory({
|
const secretRotationQueue = secretRotationQueueFactory({
|
||||||
telemetryService,
|
telemetryService,
|
||||||
secretRotationDAL,
|
secretRotationDAL,
|
||||||
@@ -732,6 +749,7 @@ export const registerRoutes = async (
|
|||||||
identityProject: identityProjectService,
|
identityProject: identityProjectService,
|
||||||
identityUa: identityUaService,
|
identityUa: identityUaService,
|
||||||
secretApprovalPolicy: sapService,
|
secretApprovalPolicy: sapService,
|
||||||
|
accessApprovalPolicy: accessApprovalPolicyService,
|
||||||
secretApprovalRequest: sarService,
|
secretApprovalRequest: sarService,
|
||||||
secretRotation: secretRotationService,
|
secretRotation: secretRotationService,
|
||||||
dynamicSecret: dynamicSecretService,
|
dynamicSecret: dynamicSecretService,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
useCreateAccessApprovalPolicy,
|
||||||
|
useDeleteAccessApprovalPolicy,
|
||||||
|
useUpdateAccessApprovalPolicy
|
||||||
|
} from "./mutation";
|
||||||
|
export { useGetAccessApprovalPolicies } from "./queries";
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { accessApprovalKeys } from "./queries";
|
||||||
|
import { TCreateAccessPolicyDTO, TDeleteSecretPolicyDTO, TUpdateAccessPolicyDTO } from "./types";
|
||||||
|
|
||||||
|
export const useCreateAccessApprovalPolicy = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TCreateAccessPolicyDTO>({
|
||||||
|
mutationFn: async ({ environment, workspaceId, approvals, approvers, name }) => {
|
||||||
|
const { data } = await apiRequest.post("/api/v1/access-approvals", {
|
||||||
|
environment,
|
||||||
|
workspaceId,
|
||||||
|
approvals,
|
||||||
|
approvers,
|
||||||
|
name
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { workspaceId }) => {
|
||||||
|
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(workspaceId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateAccessApprovalPolicy = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TUpdateAccessPolicyDTO>({
|
||||||
|
mutationFn: async ({ id, approvers, approvals, name }) => {
|
||||||
|
const { data } = await apiRequest.patch(`/api/v1/access-approvals/${id}`, {
|
||||||
|
approvals,
|
||||||
|
approvers,
|
||||||
|
name
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { workspaceId }) => {
|
||||||
|
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(workspaceId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteAccessApprovalPolicy = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TDeleteSecretPolicyDTO>({
|
||||||
|
mutationFn: async ({ id }) => {
|
||||||
|
const { data } = await apiRequest.delete(`/api/v1/access-approvals/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { workspaceId }) => {
|
||||||
|
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(workspaceId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { TAccessApprovalPolicy, TGetSecretApprovalPoliciesDTO } from "./types";
|
||||||
|
|
||||||
|
export const accessApprovalKeys = {
|
||||||
|
getAccessApprovalPolicies: (workspaceId: string) =>
|
||||||
|
[{ workspaceId }, "access-approval-policies"] as const,
|
||||||
|
getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) => [
|
||||||
|
{ workspaceId, environment },
|
||||||
|
"access-approval-policy"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchApprovalPolicies = async (workspaceId: string) => {
|
||||||
|
const { data } = await apiRequest.get<{ approvals: TAccessApprovalPolicy[] }>(
|
||||||
|
"/api/v1/access-approvals",
|
||||||
|
{ params: { workspaceId } }
|
||||||
|
);
|
||||||
|
return data.approvals;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetAccessApprovalPolicies = ({
|
||||||
|
workspaceId,
|
||||||
|
options = {}
|
||||||
|
}: TGetSecretApprovalPoliciesDTO & {
|
||||||
|
options?: UseQueryOptions<
|
||||||
|
TAccessApprovalPolicy[],
|
||||||
|
unknown,
|
||||||
|
TAccessApprovalPolicy[],
|
||||||
|
ReturnType<typeof accessApprovalKeys.getAccessApprovalPolicies>
|
||||||
|
>;
|
||||||
|
}) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: accessApprovalKeys.getAccessApprovalPolicies(workspaceId),
|
||||||
|
queryFn: () => fetchApprovalPolicies(workspaceId),
|
||||||
|
...options,
|
||||||
|
enabled: Boolean(workspaceId) && (options?.enabled ?? true)
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { WorkspaceEnv } from "../workspace/types";
|
||||||
|
|
||||||
|
export type TAccessApprovalPolicy = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
approvals: number;
|
||||||
|
envId: string;
|
||||||
|
workspace: string;
|
||||||
|
environment: WorkspaceEnv;
|
||||||
|
projectId: string;
|
||||||
|
approvers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TGetSecretApprovalPoliciesDTO = {
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TGetSecretApprovalPolicyOfBoardDTO = {
|
||||||
|
workspaceId: string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreateAccessPolicyDTO = {
|
||||||
|
workspaceId: string;
|
||||||
|
name?: string;
|
||||||
|
environment: string;
|
||||||
|
approvers?: string[];
|
||||||
|
approvals?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TUpdateAccessPolicyDTO = {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
approvers?: string[];
|
||||||
|
approvals?: number;
|
||||||
|
// for invalidating list
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDeleteSecretPolicyDTO = {
|
||||||
|
id: string;
|
||||||
|
// for invalidating list
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from "./accessApproval";
|
||||||
export * from "./admin";
|
export * from "./admin";
|
||||||
export * from "./apiKeys";
|
export * from "./apiKeys";
|
||||||
export * from "./auditLogs";
|
export * from "./auditLogs";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ZodIssue } from "zod";
|
import { ZodIssue } from "zod";
|
||||||
|
|
||||||
|
export type { TAccessApprovalPolicy } from "./accessApproval/types";
|
||||||
export type { TAuditLogStream } from "./auditLogStreams/types";
|
export type { TAuditLogStream } from "./auditLogStreams/types";
|
||||||
export type { GetAuthTokenAPI } from "./auth/types";
|
export type { GetAuthTokenAPI } from "./auth/types";
|
||||||
export type { IncidentContact } from "./incidentContacts/types";
|
export type { IncidentContact } from "./incidentContacts/types";
|
||||||
@@ -49,13 +50,13 @@ export enum ApiErrorTypes {
|
|||||||
|
|
||||||
export type TApiErrors =
|
export type TApiErrors =
|
||||||
| {
|
| {
|
||||||
error: ApiErrorTypes.ValidationError;
|
error: ApiErrorTypes.ValidationError;
|
||||||
message: ZodIssue[];
|
message: ZodIssue[];
|
||||||
statusCode: 403;
|
statusCode: 403;
|
||||||
}
|
}
|
||||||
| { error: ApiErrorTypes.ForbiddenError; message: string; statusCode: 401 }
|
| { error: ApiErrorTypes.ForbiddenError; message: string; statusCode: 401 }
|
||||||
| {
|
| {
|
||||||
statusCode: 400;
|
statusCode: 400;
|
||||||
message: string;
|
message: string;
|
||||||
error: ApiErrorTypes.BadRequestError;
|
error: ApiErrorTypes.BadRequestError;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Divider from "@app/components/basic/Divider";
|
|||||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||||
import { useWorkspace } from "@app/context";
|
import { useWorkspace } from "@app/context";
|
||||||
|
|
||||||
|
import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList";
|
||||||
import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList";
|
import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList";
|
||||||
import { SecretApprovalRequest } from "./components/SecretApprovalRequest";
|
import { SecretApprovalRequest } from "./components/SecretApprovalRequest";
|
||||||
|
|
||||||
@@ -47,8 +48,8 @@ export const SecretApprovalPage = () => {
|
|||||||
<Tab value={TabSection.SecretApprovalRequests}>Secret Approvals</Tab>
|
<Tab value={TabSection.SecretApprovalRequests}>Secret Approvals</Tab>
|
||||||
<Tab value={TabSection.SecretPolicies}>Secret Policies</Tab>
|
<Tab value={TabSection.SecretPolicies}>Secret Policies</Tab>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Tab value={TabSection.ResourceApprovalRequests}>Resource Approvals</Tab>
|
<Tab value={TabSection.ResourceApprovalRequests}>Access Approvals</Tab>
|
||||||
<Tab value={TabSection.ResourcePolicies}>Resource Policies</Tab>
|
<Tab value={TabSection.ResourcePolicies}>Access Policies</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel value={TabSection.SecretApprovalRequests}>
|
<TabPanel value={TabSection.SecretApprovalRequests}>
|
||||||
<SecretApprovalRequest />
|
<SecretApprovalRequest />
|
||||||
@@ -56,6 +57,10 @@ export const SecretApprovalPage = () => {
|
|||||||
<TabPanel value={TabSection.SecretPolicies}>
|
<TabPanel value={TabSection.SecretPolicies}>
|
||||||
<SecretApprovalPolicyList workspaceId={workspaceId} />
|
<SecretApprovalPolicyList workspaceId={workspaceId} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={TabSection.ResourcePolicies}>
|
||||||
|
<AccessApprovalPolicyList workspaceId={workspaceId} />
|
||||||
|
</TabPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
import { faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DeleteActionModal,
|
||||||
|
EmptyState,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tr,
|
||||||
|
UpgradePlanModal
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
useProjectPermission,
|
||||||
|
useSubscription
|
||||||
|
} from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { useDeleteAccessApprovalPolicy, useGetWorkspaceUsers } from "@app/hooks/api";
|
||||||
|
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
|
||||||
|
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
|
||||||
|
|
||||||
|
import { AccessApprovalPolicyRow } from "./components/AccessApprovalPolicyRow";
|
||||||
|
import { AccessPolicyForm } from "./components/AccessPolicyForm";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||||
|
const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([
|
||||||
|
"secretPolicyForm",
|
||||||
|
"deletePolicy",
|
||||||
|
"upgradePlan"
|
||||||
|
] as const);
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
|
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
||||||
|
const { data: policies, isLoading: isPoliciesLoading } = useGetAccessApprovalPolicies({
|
||||||
|
workspaceId,
|
||||||
|
options: {
|
||||||
|
enabled: permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteAccessApprovalPolicy();
|
||||||
|
|
||||||
|
const handleDeletePolicy = async () => {
|
||||||
|
const { id } = popUp.deletePolicy.data as TAccessApprovalPolicy;
|
||||||
|
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="mb-6 flex justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xl font-semibold text-mineshaft-100">Access Approval Policies</span>
|
||||||
|
<div className="mt-2 text-sm text-bunker-300">
|
||||||
|
Implement policies to prevent unauthorized secret changes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Create}
|
||||||
|
a={ProjectPermissionSub.SecretApproval}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (subscription && !subscription?.secretApproval) {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePopUpOpen("secretPolicyForm");
|
||||||
|
}}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
Create policy
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Name</Th>
|
||||||
|
<Th>Environment</Th>
|
||||||
|
<Th>Secret Path</Th>
|
||||||
|
<Th>Approval Required</Th>
|
||||||
|
<Th />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isPoliciesLoading && (
|
||||||
|
<TableSkeleton columns={4} innerKey="secret-policies" className="bg-mineshaft-700" />
|
||||||
|
)}
|
||||||
|
{!isPoliciesLoading && !policies?.length && (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={5}>
|
||||||
|
<EmptyState title="No policies found" icon={faFileShield} />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
{policies?.map((policy) => (
|
||||||
|
<AccessApprovalPolicyRow
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
policy={policy}
|
||||||
|
key={policy.id}
|
||||||
|
members={members}
|
||||||
|
onEdit={() => handlePopUpOpen("secretPolicyForm", policy)}
|
||||||
|
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<AccessPolicyForm
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
isOpen={popUp.secretPolicyForm.isOpen}
|
||||||
|
onToggle={(isOpen) => handlePopUpToggle("secretPolicyForm", isOpen)}
|
||||||
|
members={members}
|
||||||
|
editValues={popUp.secretPolicyForm.data as TAccessApprovalPolicy}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deletePolicy.isOpen}
|
||||||
|
deleteKey="remove"
|
||||||
|
title="Do you want to remove this policy?"
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
|
||||||
|
onDeleteApproved={handleDeletePolicy}
|
||||||
|
/>
|
||||||
|
<UpgradePlanModal
|
||||||
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
|
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Td,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||||
|
import { useUpdateAccessApprovalPolicy } from "@app/hooks/api";
|
||||||
|
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
|
||||||
|
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
policy: TAccessApprovalPolicy;
|
||||||
|
members?: TWorkspaceUser[];
|
||||||
|
workspaceId: string;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccessApprovalPolicyRow = ({
|
||||||
|
policy,
|
||||||
|
members = [],
|
||||||
|
workspaceId,
|
||||||
|
onEdit,
|
||||||
|
onDelete
|
||||||
|
}: Props) => {
|
||||||
|
const [selectedApprovers, setSelectedApprovers] = useState<string[]>([]);
|
||||||
|
const { mutate: updateAccessApprovalPolicy, isLoading } = useUpdateAccessApprovalPolicy();
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr>
|
||||||
|
<Td>{policy.name}</Td>
|
||||||
|
<Td>{policy.environment.slug}</Td>
|
||||||
|
<Td>
|
||||||
|
<DropdownMenu
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
updateAccessApprovalPolicy(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
id: policy.id,
|
||||||
|
approvers: selectedApprovers
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
setSelectedApprovers([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedApprovers(policy.approvers);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
asChild
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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 are allowed to 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">
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Edit}
|
||||||
|
a={ProjectPermissionSub.SecretApproval}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Edit"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton variant="plain" ariaLabel="edit" onClick={onEdit} isDisabled={!isAllowed}>
|
||||||
|
<FontAwesomeIcon icon={faPencil} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={ProjectPermissionSub.SecretApproval}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Delete"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
size="lg"
|
||||||
|
ariaLabel="edit"
|
||||||
|
onClick={onDelete}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
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 { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Select,
|
||||||
|
SelectItem
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import {
|
||||||
|
useCreateAccessApprovalPolicy,
|
||||||
|
useUpdateAccessApprovalPolicy
|
||||||
|
} from "@app/hooks/api/accessApproval";
|
||||||
|
import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types";
|
||||||
|
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen?: boolean;
|
||||||
|
onToggle: (isOpen: boolean) => void;
|
||||||
|
members?: TWorkspaceUser[];
|
||||||
|
workspaceId: string;
|
||||||
|
editValues?: TAccessApprovalPolicy;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
environment: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
secretPath: z.string().optional().nullable(),
|
||||||
|
approvals: z.number().min(1),
|
||||||
|
approvers: z.string().array().min(1)
|
||||||
|
})
|
||||||
|
.refine((data) => data.approvals <= data.approvers.length, {
|
||||||
|
path: ["approvals"],
|
||||||
|
message: "The number of approvals should be lower than the number of approvers."
|
||||||
|
});
|
||||||
|
|
||||||
|
type TFormSchema = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export const AccessPolicyForm = ({
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
members = [],
|
||||||
|
workspaceId,
|
||||||
|
editValues
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm<TFormSchema>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined
|
||||||
|
});
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const environments = currentWorkspace?.environments || [];
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) reset({});
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const isEditMode = Boolean(editValues);
|
||||||
|
|
||||||
|
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
|
||||||
|
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
|
||||||
|
|
||||||
|
const handleCreatePolicy = async (data: TFormSchema) => {
|
||||||
|
try {
|
||||||
|
await createAccessApprovalPolicy({
|
||||||
|
...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 updateAccessApprovalPolicy({
|
||||||
|
id: editValues?.id,
|
||||||
|
...data,
|
||||||
|
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 access policy" : "Create access policy"}>
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Policy Name" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} value={field.value || ""} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<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="approvers"
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Approvers 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 are allowed to 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="mt-8 flex items-center space-x-4">
|
||||||
|
<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 { AccessApprovalPolicyList } from "./AccessApprovalPolicyList";
|
||||||
Reference in New Issue
Block a user