From 68a11db1c6914f4987ca0d022ec18331a32c286c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 1 Apr 2024 09:49:22 -0700 Subject: [PATCH] Feat: Request access --- backend/scripts/generate-schema-types.ts | 2 +- .../20240330075122_access-approval-policy.ts | 38 +++ .../access-approval-policies-approvers.ts | 25 ++ .../db/schemas/access-approval-policies.ts | 23 ++ .../v1/access-approval-policy-router.ts | 133 ++++++++++ .../access-approval-policy-approver-dal.ts | 10 + .../access-approval-policy-dal.ts | 76 ++++++ .../access-approval-policy-service.ts | 206 +++++++++++++++ .../access-approval-policy-types.ts | 27 ++ backend/src/server/routes/index.ts | 18 ++ .../src/hooks/api/accessApproval/index.tsx | 6 + .../src/hooks/api/accessApproval/mutation.tsx | 58 ++++ .../src/hooks/api/accessApproval/queries.tsx | 40 +++ .../src/hooks/api/accessApproval/types.ts | 45 ++++ frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/types.ts | 17 +- .../SecretApprovalPage/SecretApprovalPage.tsx | 9 +- .../AccessApprovalPolicyList.tsx | 166 ++++++++++++ .../components/AccessApprovalPolicyRow.tsx | 145 ++++++++++ .../components/AccessPolicyForm.tsx | 250 ++++++++++++++++++ .../AccessApprovalPolicyList/index.tsx | 1 + 21 files changed, 1285 insertions(+), 11 deletions(-) create mode 100644 backend/src/db/migrations/20240330075122_access-approval-policy.ts create mode 100644 backend/src/db/schemas/access-approval-policies-approvers.ts create mode 100644 backend/src/db/schemas/access-approval-policies.ts create mode 100644 backend/src/ee/routes/v1/access-approval-policy-router.ts create mode 100644 backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts create mode 100644 backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts create mode 100644 backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts create mode 100644 backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts create mode 100644 frontend/src/hooks/api/accessApproval/index.tsx create mode 100644 frontend/src/hooks/api/accessApproval/mutation.tsx create mode 100644 frontend/src/hooks/api/accessApproval/queries.tsx create mode 100644 frontend/src/hooks/api/accessApproval/types.ts create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 8c913991f..28b736152 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -5,7 +5,7 @@ import knex from "knex"; import { writeFileSync } from "fs"; dotenv.config({ - path: path.join(__dirname, "../../.env.migration") + path: path.join(__dirname, "../../.env") }); const db = knex({ diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts new file mode 100644 index 000000000..0595d5c6e --- /dev/null +++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts @@ -0,0 +1,38 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + 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 { + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); +} diff --git a/backend/src/db/schemas/access-approval-policies-approvers.ts b/backend/src/db/schemas/access-approval-policies-approvers.ts new file mode 100644 index 000000000..4ebbfa9ae --- /dev/null +++ b/backend/src/db/schemas/access-approval-policies-approvers.ts @@ -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; +export type TAccessApprovalPoliciesApproversInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalPoliciesApproversUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts new file mode 100644 index 000000000..550049702 --- /dev/null +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -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; +export type TAccessApprovalPoliciesInsert = Omit, TImmutableDBKeys>; +export type TAccessApprovalPoliciesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts new file mode 100644 index 000000000..ec5331a89 --- /dev/null +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -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 }; + } + }); +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts new file mode 100644 index 000000000..e14854d8f --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts @@ -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; + +export const accessApprovalPolicyApproverDALFactory = (db: TDbClient) => { + const accessApprovalPolicyApproverOrm = ormify(db, TableName.AccessApprovalPolicyApprover); + return { ...accessApprovalPolicyApproverOrm }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts new file mode 100644 index 000000000..792a851c3 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -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; + +export const accessApprovalPolicyDALFactory = (db: TDbClient) => { + const accessApprovalPolicyOrm = ormify(db, TableName.AccessApprovalPolicy); + + const sapFindQuery = async (tx: Knex, filter: TFindFilter) => { + 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, 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 }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts new file mode 100644 index 000000000..d1f0ce1e7 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -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; + accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; + projectEnvDAL: Pick; + accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; + projectMembershipDAL: Pick; +}; + +export type TAccessApprovalPolicyServiceFactory = ReturnType; + +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 + }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts new file mode 100644 index 000000000..034132d04 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -0,0 +1,27 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateAccessApprovalPolicy = { + approvals: number; + environment: string; + approvers: string[]; + projectId: string; + name: string; +} & Omit; + +export type TUpdateAccessApprovalPolicy = { + policyId: string; + approvals?: number; + approvers: string[]; + name?: string; +} & Omit; + +export type TDeleteAccessApprovalPolicy = { + policyId: string; +} & Omit; + +export type TListAccessApprovalPoliciesDTO = TProjectPermission; + +export type TGetBoardAccessApprovalPolicy = { + projectId: string; + environment: string; +} & Omit; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index aeb66d93f..0a2c77c9f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2,6 +2,9 @@ import { Knex } from "knex"; import { z } from "zod"; 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 { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; @@ -207,6 +210,10 @@ export const registerRoutes = async ( const scimDAL = scimDALFactory(db); const ldapConfigDAL = ldapConfigDALFactory(db); const ldapGroupMapDAL = ldapGroupMapDALFactory(db); + + const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db); + const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); + const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); @@ -265,6 +272,15 @@ export const registerRoutes = async ( secretApprovalPolicyDAL }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); + + const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + permissionService, + projectEnvDAL, + projectMembershipDAL + }); + const samlService = samlConfigServiceFactory({ permissionService, orgBotDAL, @@ -596,6 +612,7 @@ export const registerRoutes = async ( secretVersionTagDAL, secretQueueService }); + const secretRotationQueue = secretRotationQueueFactory({ telemetryService, secretRotationDAL, @@ -732,6 +749,7 @@ export const registerRoutes = async ( identityProject: identityProjectService, identityUa: identityUaService, secretApprovalPolicy: sapService, + accessApprovalPolicy: accessApprovalPolicyService, secretApprovalRequest: sarService, secretRotation: secretRotationService, dynamicSecret: dynamicSecretService, diff --git a/frontend/src/hooks/api/accessApproval/index.tsx b/frontend/src/hooks/api/accessApproval/index.tsx new file mode 100644 index 000000000..537dfe3be --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/index.tsx @@ -0,0 +1,6 @@ +export { + useCreateAccessApprovalPolicy, + useDeleteAccessApprovalPolicy, + useUpdateAccessApprovalPolicy +} from "./mutation"; +export { useGetAccessApprovalPolicies } from "./queries"; diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx new file mode 100644 index 000000000..98e9b3f97 --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -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)); + } + }); +}; diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx new file mode 100644 index 000000000..864122593 --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/queries.tsx @@ -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 + >; +}) => + useQuery({ + queryKey: accessApprovalKeys.getAccessApprovalPolicies(workspaceId), + queryFn: () => fetchApprovalPolicies(workspaceId), + ...options, + enabled: Boolean(workspaceId) && (options?.enabled ?? true) + }); diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts new file mode 100644 index 000000000..63020166b --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -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; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 574da5a31..61e8cb666 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,3 +1,4 @@ +export * from "./accessApproval"; export * from "./admin"; export * from "./apiKeys"; export * from "./auditLogs"; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index 49949d88e..516a5d7cf 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -1,5 +1,6 @@ import { ZodIssue } from "zod"; +export type { TAccessApprovalPolicy } from "./accessApproval/types"; export type { TAuditLogStream } from "./auditLogStreams/types"; export type { GetAuthTokenAPI } from "./auth/types"; export type { IncidentContact } from "./incidentContacts/types"; @@ -49,13 +50,13 @@ export enum ApiErrorTypes { export type TApiErrors = | { - error: ApiErrorTypes.ValidationError; - message: ZodIssue[]; - statusCode: 403; - } + error: ApiErrorTypes.ValidationError; + message: ZodIssue[]; + statusCode: 403; + } | { error: ApiErrorTypes.ForbiddenError; message: string; statusCode: 401 } | { - statusCode: 400; - message: string; - error: ApiErrorTypes.BadRequestError; - }; + statusCode: 400; + message: string; + error: ApiErrorTypes.BadRequestError; + }; diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 4ab5830fe..ebbfd7b5e 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -6,6 +6,7 @@ import Divider from "@app/components/basic/Divider"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { useWorkspace } from "@app/context"; +import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList"; import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; import { SecretApprovalRequest } from "./components/SecretApprovalRequest"; @@ -47,8 +48,8 @@ export const SecretApprovalPage = () => { Secret Approvals Secret Policies - Resource Approvals - Resource Policies + Access Approvals + Access Policies @@ -56,6 +57,10 @@ export const SecretApprovalPage = () => { + + + + ); diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx new file mode 100644 index 000000000..834b54f00 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx @@ -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 ( +
+
+
+ Access Approval Policies +
+ Implement policies to prevent unauthorized secret changes. +
+
+
+ + {(isAllowed) => ( + + )} + +
+
+ + + + + + + + + + + + {isPoliciesLoading && ( + + )} + {!isPoliciesLoading && !policies?.length && ( + + + + )} + {policies?.map((policy) => ( + handlePopUpOpen("secretPolicyForm", policy)} + onDelete={() => handlePopUpOpen("deletePolicy", policy)} + /> + ))} + +
NameEnvironmentSecret PathApproval Required +
+ +
+
+ handlePopUpToggle("secretPolicyForm", isOpen)} + members={members} + editValues={popUp.secretPolicyForm.data as TAccessApprovalPolicy} + /> + handlePopUpToggle("deletePolicy", isOpen)} + onDeleteApproved={handleDeletePolicy} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add secret approval policy if you switch to Infisical's Enterprise plan." + /> +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx new file mode 100644 index 000000000..54eaae67c --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx @@ -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([]); + const { mutate: updateAccessApprovalPolicy, isLoading } = useUpdateAccessApprovalPolicy(); + const { permission } = useProjectPermission(); + + return ( + + {policy.name} + {policy.environment.slug} + + { + if (!isOpen) { + updateAccessApprovalPolicy( + { + workspaceId, + id: policy.id, + approvers: selectedApprovers + }, + { + onSettled: () => { + setSelectedApprovers([]); + } + } + ); + } else { + setSelectedApprovers(policy.approvers); + } + }} + > + + + + + + Select members that are allowed to approve changes + + {members?.map(({ id, user }) => { + const isChecked = selectedApprovers.includes(id); + return ( + { + evt.preventDefault(); + setSelectedApprovers((state) => + isChecked ? state.filter((el) => el !== id) : [...state, id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + {policy.approvals} + +
+ + {(isAllowed) => ( + + + + )} + + + {(isAllowed) => ( + + + + )} + +
+ + + ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx new file mode 100644 index 000000000..56a4bdef5 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -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; + +export const AccessPolicyForm = ({ + isOpen, + onToggle, + members = [], + workspaceId, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + 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 ( + + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + + + + + Select members that are allowed to approve changes + + {members.map(({ id, user }) => { + const isChecked = value?.includes(id); + return ( + { + evt.preventDefault(); + onChange( + isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx new file mode 100644 index 000000000..f6db07c94 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx @@ -0,0 +1 @@ +export { AccessApprovalPolicyList } from "./AccessApprovalPolicyList";