diff --git a/backend/src/db/migrations/20240724101056_access-request-groups.ts b/backend/src/db/migrations/20240724101056_access-request-groups.ts new file mode 100644 index 000000000..1576c201c --- /dev/null +++ b/backend/src/db/migrations/20240724101056_access-request-groups.ts @@ -0,0 +1,294 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + // ---------- ACCESS APPROVAL POLICY APPROVER ------------ + const hasApproverUserId = await knex.schema.hasColumn(TableName.AccessApprovalPolicyApprover, "approverUserId"); + const hasApproverId = await knex.schema.hasColumn(TableName.AccessApprovalPolicyApprover, "approverId"); + + if (!hasApproverUserId) { + // add the new fields + await knex.schema.alterTable(TableName.AccessApprovalPolicyApprover, (tb) => { + // if (hasApproverId) tb.setNullable("approverId"); + tb.uuid("approverUserId"); + tb.foreign("approverUserId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + }); + + // convert project membership id => user id + await knex(TableName.AccessApprovalPolicyApprover).update({ + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + approverUserId: knex(TableName.ProjectMembership) + .select("userId") + .where("id", knex.raw("??", [`${TableName.AccessApprovalPolicyApprover}.approverId`])) + }); + // drop the old field + await knex.schema.alterTable(TableName.AccessApprovalPolicyApprover, (tb) => { + if (hasApproverId) tb.dropColumn("approverId"); + tb.uuid("approverUserId").notNullable().alter(); + }); + } + + // ---------- ACCESS APPROVAL REQUEST ------------ + const hasAccessApprovalRequestTable = await knex.schema.hasTable(TableName.AccessApprovalRequest); + const hasRequestedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "requestedByUserId"); + const hasRequestedBy = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "requestedBy"); + + if (hasAccessApprovalRequestTable) { + // new fields + await knex.schema.alterTable(TableName.AccessApprovalRequest, (tb) => { + if (!hasRequestedByUserId) { + tb.uuid("requestedByUserId"); + tb.foreign("requestedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + } + }); + + // copy the assigned project membership => user id to new fields + await knex(TableName.AccessApprovalRequest).update({ + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + requestedByUserId: knex(TableName.ProjectMembership) + .select("userId") + .where("id", knex.raw("??", [`${TableName.AccessApprovalRequest}.requestedBy`])) + }); + // drop old fields + await knex.schema.alterTable(TableName.AccessApprovalRequest, (tb) => { + if (hasRequestedBy) { + // DROP AT A LATER TIME + // tb.dropColumn("requestedBy"); + + // ADD ALLOW NULLABLE FOR NOW + tb.uuid("requestedBy").nullable().alter(); + } + tb.uuid("requestedByUserId").notNullable().alter(); + }); + } + + // ---------- ACCESS APPROVAL REQUEST REVIEWER ------------ + const hasMemberId = await knex.schema.hasColumn(TableName.AccessApprovalRequestReviewer, "member"); + const hasReviewerUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequestReviewer, "reviewerUserId"); + if (!hasReviewerUserId) { + // new fields + await knex.schema.alterTable(TableName.AccessApprovalRequestReviewer, (tb) => { + // if (hasMemberId) tb.setNullable("member"); + tb.uuid("reviewerUserId"); + tb.foreign("reviewerUserId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + }); + // copy project membership => user id to new fields + await knex(TableName.AccessApprovalRequestReviewer).update({ + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + reviewerUserId: knex(TableName.ProjectMembership) + .select("userId") + .where("id", knex.raw("??", [`${TableName.AccessApprovalRequestReviewer}.member`])) + }); + // drop table + await knex.schema.alterTable(TableName.AccessApprovalRequestReviewer, (tb) => { + if (hasMemberId) { + // DROP AT A LATER TIME + // tb.dropColumn("member"); + + // ADD ALLOW NULLABLE FOR NOW + tb.uuid("member").nullable().alter(); + } + tb.uuid("reviewerUserId").notNullable().alter(); + }); + } + + // ---------- PROJECT USER ADDITIONAL PRIVILEGE ------------ + const projectUserAdditionalPrivilegeHasProjectMembershipId = await knex.schema.hasColumn( + TableName.ProjectUserAdditionalPrivilege, + "projectMembershipId" + ); + + const projectUserAdditionalPrivilegeHasUserId = await knex.schema.hasColumn( + TableName.ProjectUserAdditionalPrivilege, + "userId" + ); + + if (!projectUserAdditionalPrivilegeHasUserId) { + await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { + tb.uuid("userId"); + tb.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + + tb.string("projectId"); + tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + }); + + await knex(TableName.ProjectUserAdditionalPrivilege) + .update({ + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + userId: knex(TableName.ProjectMembership) + .select("userId") + .where("id", knex.raw("??", [`${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId`])), + + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + projectId: knex(TableName.ProjectMembership) + .select("projectId") + .where("id", knex.raw("??", [`${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId`])) + }) + .whereNotNull("projectMembershipId"); + + await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { + tb.uuid("userId").notNullable().alter(); + tb.string("projectId").notNullable().alter(); + }); + } + + if (projectUserAdditionalPrivilegeHasProjectMembershipId) { + await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { + // DROP AT A LATER TIME + // tb.dropColumn("projectMembershipId"); + + // ADD ALLOW NULLABLE FOR NOW + tb.uuid("projectMembershipId").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + // We remove project user additional privileges first, because it may delete records in the database where the project membership is not found. + // The project membership won't be found on records created by group members. In those cades we just delete the record and continue. + // When the additionl privilege record is deleted, it will cascade delete the access request created by the group member. + + // ---------- PROJECT USER ADDITIONAL PRIVILEGE ------------ + const hasUserId = await knex.schema.hasColumn(TableName.ProjectUserAdditionalPrivilege, "userId"); + const hasProjectMembershipId = await knex.schema.hasColumn( + TableName.ProjectUserAdditionalPrivilege, + "projectMembershipId" + ); + + // If it doesn't have the userId field, then the up migration has not run + if (!hasUserId) { + return; + } + + await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { + if (!hasProjectMembershipId) { + tb.uuid("projectMembershipId"); + tb.foreign("projectMembershipId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + } + }); + + if (!hasProjectMembershipId) { + // First, update records where a matching project membership exists + await knex(TableName.ProjectUserAdditionalPrivilege).update({ + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + projectMembershipId: knex(TableName.ProjectMembership) + .select("id") + .where("userId", knex.raw("??", [`${TableName.ProjectUserAdditionalPrivilege}.userId`])) + }); + + await knex(TableName.AccessApprovalRequest).update({ + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + projectMembershipId: knex(TableName.ProjectMembership) + .select("id") + .where("userId", knex.raw("??", [`${TableName.SecretApprovalRequest}.userId`])) + }); + + await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { + tb.dropColumn("userId"); + tb.dropColumn("projectId"); + + tb.uuid("projectMembershipId").notNullable().alter(); + }); + } + + // Then, delete records where no matching project membership was found + await knex(TableName.ProjectUserAdditionalPrivilege).whereNull("projectMembershipId").delete(); + await knex(TableName.AccessApprovalRequest).whereNull("requestedBy").delete(); + + // ---------- ACCESS APPROVAL POLICY APPROVER ------------ + const hasApproverUserId = await knex.schema.hasColumn(TableName.AccessApprovalPolicyApprover, "approverUserId"); + const hasApproverId = await knex.schema.hasColumn(TableName.AccessApprovalPolicyApprover, "approverId"); + + if (hasApproverUserId) { + await knex.schema.alterTable(TableName.AccessApprovalPolicyApprover, (tb) => { + if (!hasApproverId) { + tb.uuid("approverId"); + tb.foreign("approverId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + } + }); + + if (!hasApproverId) { + await knex(TableName.AccessApprovalPolicyApprover).update({ + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + approverId: knex(TableName.ProjectMembership) + .select("id") + .where("userId", knex.raw("??", [`${TableName.AccessApprovalPolicyApprover}.approverUserId`])) + }); + await knex.schema.alterTable(TableName.AccessApprovalPolicyApprover, (tb) => { + tb.dropColumn("approverUserId"); + + tb.uuid("approverId").notNullable().alter(); + }); + } + + // ---------- ACCESS APPROVAL REQUEST ------------ + const hasAccessApprovalRequestTable = await knex.schema.hasTable(TableName.AccessApprovalRequest); + const hasRequestedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "requestedByUserId"); + const hasRequestedBy = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "requestedBy"); + + if (hasAccessApprovalRequestTable) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (tb) => { + if (!hasRequestedBy) { + tb.uuid("requestedBy"); + tb.foreign("requestedBy").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + } + }); + + // Try to find a project membership based on the AccessApprovalRequest.requestedByUserId and AccessApprovalRequest.policyId(reference to AccessApprovalRequestPolicy).envId(reference to Environment).projectId(reference to Project) + // If a project membership is found, set the AccessApprovalRequest.requestedBy to the project membership id + // If a project membership is not found, remove the AccessApprovalRequest record + + await knex(TableName.AccessApprovalRequest).update({ + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + requestedBy: knex(TableName.ProjectMembership) + .select("id") + .where("userId", knex.raw("??", [`${TableName.AccessApprovalRequest}.requestedByUserId`])) + }); + + // Then, delete records where no matching project membership was found + await knex(TableName.AccessApprovalRequest).whereNull("requestedBy").delete(); + + await knex.schema.alterTable(TableName.AccessApprovalRequest, (tb) => { + if (hasRequestedByUserId) { + tb.dropColumn("requestedByUserId"); + } + if (hasRequestedBy) tb.uuid("requestedBy").notNullable().alter(); + }); + } + + // ---------- ACCESS APPROVAL REQUEST REVIEWER ------------ + const hasMemberId = await knex.schema.hasColumn(TableName.AccessApprovalRequestReviewer, "member"); + const hasReviewerUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequestReviewer, "reviewerUserId"); + + if (hasReviewerUserId) { + if (!hasMemberId) { + await knex.schema.alterTable(TableName.AccessApprovalRequestReviewer, (tb) => { + tb.uuid("member"); + tb.foreign("member").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + }); + } + await knex(TableName.AccessApprovalRequestReviewer).update({ + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + member: knex(TableName.ProjectMembership) + .select("id") + .where("userId", knex.raw("??", [`${TableName.AccessApprovalRequestReviewer}.reviewerUserId`])) + }); + await knex.schema.alterTable(TableName.AccessApprovalRequestReviewer, (tb) => { + tb.dropColumn("reviewerUserId"); + + tb.uuid("member").notNullable().alter(); + }); + } + } +} diff --git a/backend/src/db/schemas/access-approval-policies-approvers.ts b/backend/src/db/schemas/access-approval-policies-approvers.ts index 4ebbfa9ae..8795c486e 100644 --- a/backend/src/db/schemas/access-approval-policies-approvers.ts +++ b/backend/src/db/schemas/access-approval-policies-approvers.ts @@ -9,10 +9,10 @@ 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() + updatedAt: z.date(), + approverUserId: z.string().uuid() }); export type TAccessApprovalPoliciesApprovers = z.infer; diff --git a/backend/src/db/schemas/access-approval-requests-reviewers.ts b/backend/src/db/schemas/access-approval-requests-reviewers.ts index 509fd7425..de9489288 100644 --- a/backend/src/db/schemas/access-approval-requests-reviewers.ts +++ b/backend/src/db/schemas/access-approval-requests-reviewers.ts @@ -9,11 +9,11 @@ import { TImmutableDBKeys } from "./models"; export const AccessApprovalRequestsReviewersSchema = z.object({ id: z.string().uuid(), - member: z.string().uuid(), status: z.string(), requestId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + reviewerUserId: z.string().uuid() }); export type TAccessApprovalRequestsReviewers = z.infer; diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts index bd598bac6..5102c0eae 100644 --- a/backend/src/db/schemas/access-approval-requests.ts +++ b/backend/src/db/schemas/access-approval-requests.ts @@ -11,12 +11,12 @@ export const AccessApprovalRequestsSchema = z.object({ id: z.string().uuid(), policyId: z.string().uuid(), privilegeId: z.string().uuid().nullable().optional(), - requestedBy: z.string().uuid(), isTemporary: z.boolean(), temporaryRange: z.string().nullable().optional(), permissions: z.unknown(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + requestedByUserId: z.string().uuid() }); export type TAccessApprovalRequests = z.infer; diff --git a/backend/src/db/schemas/project-user-additional-privilege.ts b/backend/src/db/schemas/project-user-additional-privilege.ts index 0fd0e5faa..bd69f1484 100644 --- a/backend/src/db/schemas/project-user-additional-privilege.ts +++ b/backend/src/db/schemas/project-user-additional-privilege.ts @@ -10,7 +10,6 @@ import { TImmutableDBKeys } from "./models"; export const ProjectUserAdditionalPrivilegeSchema = z.object({ id: z.string().uuid(), slug: z.string(), - projectMembershipId: z.string().uuid(), isTemporary: z.boolean().default(false), temporaryMode: z.string().nullable().optional(), temporaryRange: z.string().nullable().optional(), @@ -18,7 +17,9 @@ export const ProjectUserAdditionalPrivilegeSchema = z.object({ temporaryAccessEndTime: z.date().nullable().optional(), permissions: z.unknown(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + userId: z.string().uuid(), + projectId: z.string() }); export type TProjectUserAdditionalPrivilege = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index 8a3f93109..b6a5fd477 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -17,11 +17,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi name: z.string().optional(), secretPath: z.string().trim().default("/"), environment: z.string(), - approvers: z.string().array().min(1), + approverUserIds: z.string().array().min(1), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard) }) - .refine((data) => data.approvals <= data.approvers.length, { + .refine((data) => data.approvals <= data.approverUserIds.length, { path: ["approvals"], message: "The number of approvals should be lower than the number of approvers." }), @@ -56,7 +56,16 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }), response: { 200: z.object({ - approvals: sapPubSchema.extend({ approvers: z.string().array(), secretPath: z.string().optional() }).array() + approvals: sapPubSchema + .extend({ + userApprovers: z + .object({ + userId: z.string() + }) + .array(), + secretPath: z.string().optional().nullable() + }) + .array() }) } }, @@ -69,6 +78,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi actorOrgId: req.permission.orgId, projectSlug: req.query.projectSlug }); + return { approvals }; } }); @@ -117,11 +127,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .trim() .optional() .transform((val) => (val === "" ? "/" : val)), - approvers: z.string().array().min(1), + approverUserIds: z.string().array().min(1), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard) }) - .refine((data) => data.approvals <= data.approvers.length, { + .refine((data) => data.approvals <= data.approverUserIds.length, { path: ["approvals"], message: "The number of approvals should be lower than the number of approvers." }), diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 972289b04..7dbb62fc2 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -1,10 +1,19 @@ import { z } from "zod"; -import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas"; +import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +const approvalRequestUser = z.object({ userId: z.string() }).merge( + UsersSchema.pick({ + email: true, + firstName: true, + lastName: true, + username: true + }) +); + export const registerAccessApprovalRequestRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", @@ -104,10 +113,11 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv }), reviewers: z .object({ - member: z.string(), + userId: z.string(), status: z.string() }) - .array() + .array(), + requestedByUser: approvalRequestUser }).array() }) } 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 index 77ae430c6..c224bd3ca 100644 --- 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 @@ -1,9 +1,9 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TAccessApprovalPolicies } from "@app/db/schemas"; +import { AccessApprovalPoliciesSchema, TableName, TAccessApprovalPolicies } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { buildFindFilter, mergeOneToManyRelation, ormify, selectAllTableCols, TFindFilter } from "@app/lib/knex"; +import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; export type TAccessApprovalPolicyDALFactory = ReturnType; @@ -15,12 +15,12 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { // eslint-disable-next-line .where(buildFindFilter(filter)) .join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) - .join( + .leftJoin( TableName.AccessApprovalPolicyApprover, `${TableName.AccessApprovalPolicy}.id`, `${TableName.AccessApprovalPolicyApprover}.policyId` ) - .select(tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(tx.ref("approverUserId").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")) @@ -35,18 +35,30 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { const doc = await accessApprovalPolicyFindQuery(tx || db.replicaNode(), { [`${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 } + const formattedDoc = sqlNestRelationships({ + data: doc, + key: "id", + parentMapper: (data) => ({ + environment: { + id: data.envId, + name: data.envName, + slug: data.envSlug + }, + projectId: data.projectId, + ...AccessApprovalPoliciesSchema.parse(data) }), - ({ approverId }) => approverId, - "approvers" - ); - return formatedDoc?.[0]; + childrenMapper: [ + { + key: "approverUserId", + label: "userApprovers" as const, + mapper: ({ approverUserId }) => ({ + userId: approverUserId + }) + } + ] + }); + + return formattedDoc?.[0]; } catch (error) { throw new DatabaseError({ error, name: "FindById" }); } @@ -55,18 +67,32 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { const find = async (filter: TFindFilter, tx?: Knex) => { try { const docs = await accessApprovalPolicyFindQuery(tx || db.replicaNode(), filter); - const formatedDoc = mergeOneToManyRelation( - docs, - "id", - ({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({ - ...el, - envId, - environment: { id: envId, name, slug } + + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (data) => ({ + environment: { + id: data.envId, + name: data.envName, + slug: data.envSlug + }, + projectId: data.projectId, + ...AccessApprovalPoliciesSchema.parse(data) + // secretPath: data.secretPath || undefined, }), - ({ approverId }) => approverId, - "approvers" - ); - return formatedDoc.map((policy) => ({ ...policy, secretPath: policy.secretPath || undefined })); + childrenMapper: [ + { + key: "approverUserId", + label: "userApprovers" as const, + mapper: ({ approverUserId }) => ({ + userId: approverUserId + }) + } + ] + }); + + return formattedDocs; } catch (error) { throw new DatabaseError({ error, name: "Find" }); } 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 index c91fbf970..9cff9a17f 100644 --- 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 @@ -34,8 +34,7 @@ export const accessApprovalPolicyServiceFactory = ({ accessApprovalPolicyApproverDAL, permissionService, projectEnvDAL, - projectDAL, - projectMembershipDAL + projectDAL }: TSecretApprovalPolicyServiceFactoryDep) => { const createAccessApprovalPolicy = async ({ name, @@ -45,7 +44,7 @@ export const accessApprovalPolicyServiceFactory = ({ secretPath, actorAuthMethod, approvals, - approvers, + approverUserIds, projectSlug, environment, enforcementLevel @@ -53,7 +52,7 @@ export const accessApprovalPolicyServiceFactory = ({ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new BadRequestError({ message: "Project not found" }); - if (approvals > approvers.length) + if (approvals > approverUserIds.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); const { permission } = await permissionService.getProjectPermission( @@ -70,15 +69,6 @@ export const accessApprovalPolicyServiceFactory = ({ const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); if (!env) throw new BadRequestError({ message: "Environment not found" }); - const secretApprovers = await projectMembershipDAL.find({ - projectId: project.id, - $in: { id: approvers } - }); - - if (secretApprovers.length !== approvers.length) { - throw new BadRequestError({ message: "Approver not found in project" }); - } - await verifyApprovers({ projectId: project.id, orgId: actorOrgId, @@ -86,7 +76,7 @@ export const accessApprovalPolicyServiceFactory = ({ secretPath, actorAuthMethod, permissionService, - userIds: secretApprovers.map((approver) => approver.userId) + userIds: approverUserIds }); const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { @@ -101,8 +91,8 @@ export const accessApprovalPolicyServiceFactory = ({ tx ); await accessApprovalPolicyApproverDAL.insertMany( - secretApprovers.map(({ id }) => ({ - approverId: id, + approverUserIds.map((userId) => ({ + approverUserId: userId, policyId: doc.id })), tx @@ -138,7 +128,7 @@ export const accessApprovalPolicyServiceFactory = ({ const updateAccessApprovalPolicy = async ({ policyId, - approvers, + approverUserIds, secretPath, name, actorId, @@ -171,16 +161,7 @@ export const accessApprovalPolicyServiceFactory = ({ }, 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 (approverUserIds) { await verifyApprovers({ projectId: accessApprovalPolicy.projectId, orgId: actorOrgId, @@ -188,15 +169,13 @@ export const accessApprovalPolicyServiceFactory = ({ secretPath: doc.secretPath!, actorAuthMethod, permissionService, - userIds: secretApprovers.map((approver) => approver.userId) + userIds: approverUserIds }); - if (secretApprovers.length !== approvers.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, + approverUserIds.map((userId) => ({ + approverUserId: userId, policyId: doc.id })), tx 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 index fdb6fc8bb..83f4ffab6 100644 --- 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 @@ -17,7 +17,7 @@ export type TCreateAccessApprovalPolicy = { approvals: number; secretPath: string; environment: string; - approvers: string[]; + approverUserIds: string[]; projectSlug: string; name: string; enforcementLevel: EnforcementLevel; @@ -26,7 +26,7 @@ export type TCreateAccessApprovalPolicy = { export type TUpdateAccessApprovalPolicy = { policyId: string; approvals?: number; - approvers?: string[]; + approverUserIds?: string[]; secretPath?: string; name?: string; enforcementLevel?: EnforcementLevel; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index b293a38d6..48e2d88bf 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests } from "@app/db/schemas"; +import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests, TUsers } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; @@ -40,6 +40,12 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicyApprover}.policyId` ) + .join( + db(TableName.Users).as("requestedByUser"), + `${TableName.AccessApprovalRequest}.requestedByUserId`, + `requestedByUser.id` + ) + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .select(selectAllTableCols(TableName.AccessApprovalRequest)) @@ -52,7 +58,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId") ) - .select(db.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select( db.ref("projectId").withSchema(TableName.Environment), @@ -61,15 +67,20 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ) .select( - db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"), + db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId"), db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus") ) + // TODO: ADD SUPPORT FOR GROUPS!!!! .select( - db - .ref("projectMembershipId") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("privilegeMembershipId"), + db.ref("email").withSchema("requestedByUser").as("requestedByUserEmail"), + db.ref("username").withSchema("requestedByUser").as("requestedByUserUsername"), + db.ref("firstName").withSchema("requestedByUser").as("requestedByUserFirstName"), + db.ref("lastName").withSchema("requestedByUser").as("requestedByUserLastName"), + + db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeUserId"), + db.ref("projectId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeMembershipId"), + db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeIsTemporary"), db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryMode"), db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryRange"), @@ -102,9 +113,18 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { enforcementLevel: doc.policyEnforcementLevel, envId: doc.policyEnvId }, + requestedByUser: { + userId: doc.requestedByUserId, + email: doc.requestedByUserEmail, + firstName: doc.requestedByUserFirstName, + lastName: doc.requestedByUserLastName, + username: doc.requestedByUserUsername + }, privilege: doc.privilegeId ? { membershipId: doc.privilegeMembershipId, + userId: doc.privilegeUserId, + projectId: doc.projectId, isTemporary: doc.privilegeIsTemporary, temporaryMode: doc.privilegeTemporaryMode, temporaryRange: doc.privilegeTemporaryRange, @@ -118,11 +138,11 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { }), childrenMapper: [ { - key: "reviewerMemberId", + key: "reviewerUserId", label: "reviewers" as const, - mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined) }, - { key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId } + { key: "approverUserId", label: "approvers" as const, mapper: ({ approverUserId }) => approverUserId } ] }); @@ -146,30 +166,65 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicy}.id` ) + .join( + db(TableName.Users).as("requestedByUser"), + `${TableName.AccessApprovalRequest}.requestedByUserId`, + `requestedByUser.id` + ) + .join( TableName.AccessApprovalPolicyApprover, `${TableName.AccessApprovalPolicy}.id`, `${TableName.AccessApprovalPolicyApprover}.policyId` ) + + .join( + db(TableName.Users).as("accessApprovalPolicyApproverUser"), + `${TableName.AccessApprovalPolicyApprover}.approverUserId`, + "accessApprovalPolicyApproverUser.id" + ) + .leftJoin( TableName.AccessApprovalRequestReviewer, `${TableName.AccessApprovalRequest}.id`, `${TableName.AccessApprovalRequestReviewer}.requestId` ) + .leftJoin( + db(TableName.Users).as("accessApprovalReviewerUser"), + `${TableName.AccessApprovalRequestReviewer}.reviewerUserId`, + `accessApprovalReviewerUser.id` + ) + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .select(selectAllTableCols(TableName.AccessApprovalRequest)) .select( - tx.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"), + tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover), + tx.ref("email").withSchema("accessApprovalPolicyApproverUser").as("approverEmail"), + tx.ref("username").withSchema("accessApprovalPolicyApproverUser").as("approverUsername"), + tx.ref("firstName").withSchema("accessApprovalPolicyApproverUser").as("approverFirstName"), + tx.ref("lastName").withSchema("accessApprovalPolicyApproverUser").as("approverLastName"), + tx.ref("email").withSchema("requestedByUser").as("requestedByUserEmail"), + tx.ref("username").withSchema("requestedByUser").as("requestedByUserUsername"), + tx.ref("firstName").withSchema("requestedByUser").as("requestedByUserFirstName"), + tx.ref("lastName").withSchema("requestedByUser").as("requestedByUserLastName"), + + tx.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer), + tx.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"), + + tx.ref("email").withSchema("accessApprovalReviewerUser").as("reviewerEmail"), + tx.ref("username").withSchema("accessApprovalReviewerUser").as("reviewerUsername"), + tx.ref("firstName").withSchema("accessApprovalReviewerUser").as("reviewerFirstName"), + tx.ref("lastName").withSchema("accessApprovalReviewerUser").as("reviewerLastName"), + tx.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"), tx.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"), tx.ref("projectId").withSchema(TableName.Environment), tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), - tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), - tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover) + tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals") ); const findById = async (id: string, tx?: Knex) => { @@ -189,15 +244,45 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { approvals: el.policyApprovals, secretPath: el.policySecretPath, enforcementLevel: el.policyEnforcementLevel + }, + requestedByUser: { + userId: el.requestedByUserId, + email: el.requestedByUserEmail, + firstName: el.requestedByUserFirstName, + lastName: el.requestedByUserLastName, + username: el.requestedByUserUsername } }), childrenMapper: [ { - key: "reviewerMemberId", + key: "reviewerUserId", label: "reviewers" as const, - mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + mapper: ({ + reviewerUserId: userId, + reviewerStatus: status, + reviewerEmail: email, + reviewerLastName: lastName, + reviewerUsername: username, + reviewerFirstName: firstName + }) => (userId ? { userId, status, email, firstName, lastName, username } : undefined) }, - { key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId } + { + key: "approverUserId", + label: "approvers" as const, + mapper: ({ + approverUserId, + approverEmail: email, + approverUsername: username, + approverLastName: lastName, + approverFirstName: firstName + }) => ({ + userId: approverUserId, + email, + firstName, + lastName, + username + }) + } ] }); if (!formatedDoc?.[0]) return; @@ -235,7 +320,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { .where(`${TableName.Environment}.projectId`, projectId) .select(selectAllTableCols(TableName.AccessApprovalRequest)) .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")) - .select(db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId")); + .select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId")); const formattedRequests = sqlNestRelationships({ data: accessRequests, @@ -245,9 +330,10 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { }), childrenMapper: [ { - key: "reviewerMemberId", + key: "reviewerUserId", label: "reviewers" as const, - mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + mapper: ({ reviewerUserId: reviewer, reviewerStatus: status }) => + reviewer ? { reviewer, status } : undefined } ] }); diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index becdb78da..544229609 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -52,7 +52,10 @@ type TSecretApprovalRequestServiceFactoryDep = { >; projectMembershipDAL: Pick; smtpService: Pick; - userDAL: Pick; + userDAL: Pick< + TUserDALFactory, + "findUserByProjectMembershipId" | "findUsersByProjectMembershipIds" | "find" | "findById" + >; }; export type TAccessApprovalRequestServiceFactory = ReturnType; @@ -94,7 +97,7 @@ export const accessApprovalRequestServiceFactory = ({ ); if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); - const requestedByUser = await userDAL.findUserByProjectMembershipId(membership.id); + const requestedByUser = await userDAL.findById(actorId); if (!requestedByUser) throw new UnauthorizedError({ message: "User not found" }); await projectDAL.checkProjectUpgradeStatus(project.id); @@ -114,13 +117,15 @@ export const accessApprovalRequestServiceFactory = ({ policyId: policy.id }); - const approverUsers = await userDAL.findUsersByProjectMembershipIds( - approvers.map((approver) => approver.approverId) - ); + const approverUsers = await userDAL.find({ + $in: { + id: approvers.map((approver) => approver.approverUserId) + } + }); const duplicateRequests = await accessApprovalRequestDAL.find({ policyId: policy.id, - requestedBy: membership.id, + requestedByUserId: actorId, permissions: JSON.stringify(requestedPermissions), isTemporary }); @@ -153,7 +158,7 @@ export const accessApprovalRequestServiceFactory = ({ const approvalRequest = await accessApprovalRequestDAL.create( { policyId: policy.id, - requestedBy: membership.id, + requestedByUserId: actorId, temporaryRange: temporaryRange || null, permissions: JSON.stringify(requestedPermissions), isTemporary @@ -212,7 +217,7 @@ export const accessApprovalRequestServiceFactory = ({ let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id)); if (authorProjectMembershipId) { - requests = requests.filter((request) => request.requestedBy === authorProjectMembershipId); + requests = requests.filter((request) => request.requestedByUserId === actorId); } if (envSlug) { @@ -246,8 +251,8 @@ export const accessApprovalRequestServiceFactory = ({ if ( !hasRole(ProjectMembershipRole.Admin) && - accessApprovalRequest.requestedBy !== membership.id && // The request wasn't made by the current user - !policy.approvers.find((approverId) => approverId === membership.id) // The request isn't performed by an assigned approver + accessApprovalRequest.requestedByUserId !== actorId && // The request wasn't made by the current user + !policy.approvers.find((approver) => approver.userId === actorId) // The request isn't performed by an assigned approver ) { throw new UnauthorizedError({ message: "You are not authorized to approve this request" }); } @@ -273,7 +278,7 @@ export const accessApprovalRequestServiceFactory = ({ const review = await accessApprovalRequestReviewerDAL.findOne( { requestId: accessApprovalRequest.id, - member: membership.id + reviewerUserId: actorId }, tx ); @@ -282,7 +287,7 @@ export const accessApprovalRequestServiceFactory = ({ { status, requestId: accessApprovalRequest.id, - member: membership.id + reviewerUserId: actorId }, tx ); @@ -303,7 +308,8 @@ export const accessApprovalRequestServiceFactory = ({ // Permanent access const privilege = await additionalPrivilegeDAL.create( { - projectMembershipId: accessApprovalRequest.requestedBy, + userId: accessApprovalRequest.requestedByUserId, + projectId: accessApprovalRequest.projectId, slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, permissions: JSON.stringify(accessApprovalRequest.permissions) }, @@ -317,7 +323,8 @@ export const accessApprovalRequestServiceFactory = ({ const privilege = await additionalPrivilegeDAL.create( { - projectMembershipId: accessApprovalRequest.requestedBy, + userId: accessApprovalRequest.requestedByUserId, + projectId: accessApprovalRequest.projectId, slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, permissions: JSON.stringify(accessApprovalRequest.permissions), isTemporary: true, diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index d228ae109..cd392d3c7 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -66,6 +66,7 @@ export const permissionDALFactory = (db: TDbClient) => { `${TableName.GroupProjectMembershipRole}.projectMembershipId`, `${TableName.GroupProjectMembership}.id` ) + .leftJoin( TableName.ProjectRoles, `${TableName.GroupProjectMembershipRole}.customRoleId`, @@ -73,6 +74,12 @@ export const permissionDALFactory = (db: TDbClient) => { ) .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) + + .leftJoin( + TableName.ProjectUserAdditionalPrivilege, + `${TableName.GroupProjectMembership}.projectId`, + `${TableName.Project}.id` + ) .select(selectAllTableCols(TableName.GroupProjectMembershipRole)) .select( db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"), @@ -81,9 +88,30 @@ export const permissionDALFactory = (db: TDbClient) => { db.ref("projectId").withSchema(TableName.GroupProjectMembership), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("orgId").withSchema(TableName.Project), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug") - ) - .select("permissions"); + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + + db.ref("permissions").withSchema(TableName.ProjectRoles).as("permissions"), + // db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("apPermissions") + // Additional Privileges + db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApId"), + db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApPermissions"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"), + + db.ref("projectId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApProjectId"), + db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApUserId"), + + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userApTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userApTemporaryAccessEndTime") + ); + // .select(`${TableName.ProjectRoles}.permissions`); const docs = await db(TableName.ProjectMembership) .join( @@ -98,12 +126,13 @@ export const permissionDALFactory = (db: TDbClient) => { ) .leftJoin( TableName.ProjectUserAdditionalPrivilege, - `${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId`, - `${TableName.ProjectMembership}.id` + `${TableName.ProjectUserAdditionalPrivilege}.projectId`, + `${TableName.ProjectMembership}.projectId` ) + .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) - .where("userId", userId) + .where(`${TableName.ProjectMembership}.userId`, userId) .where(`${TableName.ProjectMembership}.projectId`, projectId) .select(selectAllTableCols(TableName.ProjectUserMembershipRole)) .select( @@ -120,6 +149,10 @@ export const permissionDALFactory = (db: TDbClient) => { db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"), db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"), db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"), + + db.ref("projectId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApProjectId"), + db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApUserId"), + db .ref("temporaryAccessStartTime") .withSchema(TableName.ProjectUserAdditionalPrivilege) @@ -198,6 +231,31 @@ export const permissionDALFactory = (db: TDbClient) => { permissions: z.unknown(), customRoleSlug: z.string().optional().nullable() }).parse(data) + }, + { + key: "userApId", + label: "additionalPrivileges" as const, + mapper: ({ + userApId, + userApProjectId, + userApUserId, + userApPermissions, + userApIsTemporary, + userApTemporaryMode, + userApTemporaryRange, + userApTemporaryAccessEndTime, + userApTemporaryAccessStartTime + }) => ({ + id: userApId, + userId: userApUserId, + projectId: userApProjectId, + permissions: userApPermissions, + temporaryRange: userApTemporaryRange, + temporaryMode: userApTemporaryMode, + temporaryAccessEndTime: userApTemporaryAccessEndTime, + temporaryAccessStartTime: userApTemporaryAccessStartTime, + isTemporary: userApIsTemporary + }) } ] }) @@ -218,15 +276,24 @@ export const permissionDALFactory = (db: TDbClient) => { !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) ) ?? []; - const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter( - ({ isTemporary, temporaryAccessEndTime }) => - !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) - ); + const activeAdditionalPrivileges = + permission?.[0]?.additionalPrivileges?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) ?? []; + + const activeGroupAdditionalPrivileges = + groupPermission?.[0]?.additionalPrivileges?.filter( + ({ isTemporary, temporaryAccessEndTime, userId: apUserId, projectId: apProjectId }) => + apProjectId === projectId && + apUserId === userId && + (!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)) + ) ?? []; return { ...(permission[0] || groupPermission[0]), roles: [...activeRoles, ...activeGroupRoles], - additionalPrivileges: activeAdditionalPrivileges + additionalPrivileges: [...activeAdditionalPrivileges, ...activeGroupAdditionalPrivileges] }; } catch (error) { throw new DatabaseError({ error, name: "GetProjectPermission" }); diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts index c9ff2c7e0..c5b06cad5 100644 --- a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts @@ -18,7 +18,7 @@ import { type TProjectUserAdditionalPrivilegeServiceFactoryDep = { projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick; permissionService: Pick; }; @@ -53,12 +53,17 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); - const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ slug, projectMembershipId }); + const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ + slug, + projectId: projectMembership.projectId, + userId: projectMembership.userId + }); if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); if (!dto.isTemporary) { const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ - projectMembershipId, + userId: projectMembership.userId, + projectId: projectMembership.projectId, slug, permissions: customPermission }); @@ -67,7 +72,8 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ - projectMembershipId, + projectId: projectMembership.projectId, + userId: projectMembership.userId, slug, permissions: customPermission, isTemporary: true, @@ -90,7 +96,11 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); - const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + const projectMembership = await projectMembershipDAL.findOne({ + userId: userPrivilege.userId, + projectId: userPrivilege.projectId + }); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); const { permission } = await permissionService.getProjectPermission( @@ -105,7 +115,8 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ if (dto?.slug) { const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ slug: dto.slug, - projectMembershipId: projectMembership.id + userId: projectMembership.id, + projectId: projectMembership.projectId }); if (existingSlug && existingSlug.id !== userPrivilege.id) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); @@ -138,7 +149,10 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); - const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + const projectMembership = await projectMembershipDAL.findOne({ + userId: userPrivilege.userId, + projectId: userPrivilege.projectId + }); if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); const { permission } = await permissionService.getProjectPermission( @@ -164,7 +178,10 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); - const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + const projectMembership = await projectMembershipDAL.findOne({ + userId: userPrivilege.userId, + projectId: userPrivilege.projectId + }); if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); const { permission } = await permissionService.getProjectPermission( @@ -198,7 +215,10 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); - const userPrivileges = await projectUserAdditionalPrivilegeDAL.find({ projectMembershipId }); + const userPrivileges = await projectUserAdditionalPrivilegeDAL.find({ + userId: projectMembership.userId, + projectId: projectMembership.projectId + }); return userPrivileges; }; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 5787924dd..2004486eb 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -31,6 +31,7 @@ import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { buildScimGroup, buildScimGroupList, @@ -93,6 +94,7 @@ type TScimServiceFactoryDep = { licenseService: Pick; permissionService: Pick; smtpService: Pick; + projectUserAdditionalPrivilegeDAL: Pick; }; export type TScimServiceFactory = ReturnType; @@ -112,6 +114,7 @@ export const scimServiceFactory = ({ projectKeyDAL, projectBotDAL, permissionService, + projectUserAdditionalPrivilegeDAL, smtpService }: TScimServiceFactoryDep) => { const createScimToken = async ({ @@ -558,6 +561,7 @@ export const scimServiceFactory = ({ orgId: membership.orgId, orgDAL, projectMembershipDAL, + projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, licenseService diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index d49c7aace..d541947db 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -412,6 +412,7 @@ export const registerRoutes = async ( orgDAL, orgMembershipDAL, projectDAL, + projectUserAdditionalPrivilegeDAL, projectMembershipDAL, groupDAL, groupProjectDAL, @@ -477,6 +478,7 @@ export const registerRoutes = async ( orgDAL, incidentContactDAL, tokenService, + projectUserAdditionalPrivilegeDAL, projectDAL, projectMembershipDAL, orgMembershipDAL, @@ -549,10 +551,12 @@ export const registerRoutes = async ( projectBotDAL, orgDAL, userDAL, + projectUserAdditionalPrivilegeDAL, userGroupMembershipDAL, smtpService, projectKeyDAL, projectRoleDAL, + groupProjectDAL, licenseService }); const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 7619171da..ac9703f07 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -59,12 +59,19 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + querystring: z.object({ + includeGroupMembers: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }), params: z.object({ workspaceId: z.string().trim() }), response: { 200: z.object({ users: ProjectMembershipsSchema.extend({ + isGroupMember: z.boolean(), user: UsersSchema.pick({ email: true, username: true, @@ -99,9 +106,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, + includeGroupMembers: req.query.includeGroupMembers, projectId: req.params.workspaceId, actorOrgId: req.permission.orgId }); + return { users }; } }); diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index a1d276376..fedfcfbc3 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, sqlNestRelationships } from "@app/lib/knex"; @@ -95,5 +95,107 @@ export const groupProjectDALFactory = (db: TDbClient) => { } }; - return { ...groupProjectOrm, findByProjectId }; + // The GroupProjectMembership table has a reference to the project (projectId) AND the group (groupId). + // We need to join the GroupProjectMembership table with the Groups table to get the group name and slug. + // We also need to join the GroupProjectMembershipRole table to get the role of the group in the project. + const findAllProjectGroupMembers = async (projectId: string) => { + const docs = await db(TableName.UserGroupMembership) + // Join the GroupProjectMembership table with the Groups table to get the group name and slug. + .join( + TableName.GroupProjectMembership, + `${TableName.UserGroupMembership}.groupId`, + `${TableName.GroupProjectMembership}.groupId` // this gives us access to the project id in the group membership + ) + + .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) + + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + + .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .join( + TableName.GroupProjectMembershipRole, + `${TableName.GroupProjectMembershipRole}.projectMembershipId`, + `${TableName.GroupProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.GroupProjectMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .select( + db.ref("id").withSchema(TableName.GroupProjectMembership), + db.ref("isGhost").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("role").withSchema(TableName.GroupProjectMembershipRole), + db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.GroupProjectMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.GroupProjectMembershipRole), + db.ref("isTemporary").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole), + db.ref("name").as("projectName").withSchema(TableName.Project) + ) + .where({ isGhost: false }); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId, projectName }) => ({ + isGroupMember: true, + id, + userId, + projectId, + project: { + id: projectId, + name: projectName + }, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; + }; + + return { ...groupProjectOrm, findByProjectId, findAllProjectGroupMembers }; }; diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts index a63ffabee..559ccc7db 100644 --- a/backend/src/services/org/org-fns.ts +++ b/backend/src/services/org/org-fns.ts @@ -1,4 +1,5 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; @@ -12,6 +13,7 @@ type TDeleteOrgMembership = { projectKeyDAL: Pick; userAliasDAL: Pick; licenseService: Pick; + projectUserAdditionalPrivilegeDAL: Pick; }; export const deleteOrgMembershipFn = async ({ @@ -19,6 +21,7 @@ export const deleteOrgMembershipFn = async ({ orgId, orgDAL, projectMembershipDAL, + projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, licenseService @@ -39,6 +42,13 @@ export const deleteOrgMembershipFn = async ({ tx ); + await projectUserAdditionalPrivilegeDAL.delete( + { + userId: orgMembership.userId + }, + tx + ); + // Get all the project memberships of the user in the organization const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 3ffc2e203..23e0aeeff 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -10,6 +10,7 @@ import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { TSamlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; @@ -67,6 +68,7 @@ type TOrgServiceFactoryDep = { TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount" | "generateOrgCustomerId" | "removeOrgCustomer" >; + projectUserAdditionalPrivilegeDAL: Pick; }; export type TOrgServiceFactory = ReturnType; @@ -84,6 +86,7 @@ export const orgServiceFactory = ({ projectMembershipDAL, projectKeyDAL, orgMembershipDAL, + projectUserAdditionalPrivilegeDAL, tokenService, orgBotDAL, licenseService, @@ -632,6 +635,7 @@ export const orgServiceFactory = ({ orgId, orgDAL, projectMembershipDAL, + projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, licenseService diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 8f87e8d55..6996625f7 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -12,6 +12,7 @@ import { import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; @@ -19,6 +20,7 @@ import { groupBy } from "@app/lib/fn"; import { TUserGroupMembershipDALFactory } from "../../ee/services/group/user-group-membership-dal"; import { ActorType } from "../auth/auth-type"; +import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { assignWorkspaceKeysToMembers } from "../project/project-fns"; @@ -54,6 +56,8 @@ type TProjectMembershipServiceFactoryDep = { projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; + projectUserAdditionalPrivilegeDAL: Pick; + groupProjectDAL: TGroupProjectDALFactory; }; export type TProjectMembershipServiceFactory = ReturnType; @@ -66,8 +70,10 @@ export const projectMembershipServiceFactory = ({ projectRoleDAL, projectBotDAL, orgDAL, + projectUserAdditionalPrivilegeDAL, userDAL, userGroupMembershipDAL, + groupProjectDAL, projectDAL, projectKeyDAL, licenseService @@ -77,6 +83,7 @@ export const projectMembershipServiceFactory = ({ actor, actorOrgId, actorAuthMethod, + includeGroupMembers, projectId }: TGetProjectMembershipDTO) => { const { permission } = await permissionService.getProjectPermission( @@ -88,7 +95,25 @@ export const projectMembershipServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); - return projectMembershipDAL.findAllProjectMembers(projectId); + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + + // projectMembers[0].project + if (includeGroupMembers) { + const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId); + + const allMembers = [ + ...projectMembers.map((m) => ({ ...m, isGroupMember: false })), + ...groupMembers.map((m) => ({ ...m, isGroupMember: true })) + ]; + + // Ensure the userId is unique + const membersIds = new Set(allMembers.map((entity) => entity.user.id)); + const uniqueMembers = allMembers.filter((entity) => membersIds.has(entity.user.id)); + + return uniqueMembers; + } + + return projectMembers.map((m) => ({ ...m, isGroupMember: false })); }; const getProjectMembershipByUsername = async ({ @@ -502,6 +527,16 @@ export const projectMembershipServiceFactory = ({ ); const memberships = await projectMembershipDAL.transaction(async (tx) => { + await projectUserAdditionalPrivilegeDAL.delete( + { + projectId, + $in: { + userId: projectMembers.map((membership) => membership.user.id) + } + }, + tx + ); + const deletedMemberships = await projectMembershipDAL.delete( { projectId, @@ -564,12 +599,25 @@ export const projectMembershipServiceFactory = ({ }); } - const deletedMembership = ( - await projectMembershipDAL.delete({ - projectId: project.id, - userId: actorId - }) - )?.[0]; + const deletedMembership = await projectMembershipDAL.transaction(async (tx) => { + await projectUserAdditionalPrivilegeDAL.delete( + { + projectId: project.id, + userId: actorId + }, + tx + ); + const membership = ( + await projectMembershipDAL.delete( + { + projectId: project.id, + userId: actorId + }, + tx + ) + )?.[0]; + return membership; + }); if (!deletedMembership) { throw new BadRequestError({ message: "Failed to leave project" }); diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index dc3a62016..21efaf691 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,6 +1,6 @@ import { TProjectPermission } from "@app/lib/types"; -export type TGetProjectMembershipDTO = TProjectPermission; +export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean } & TProjectPermission; export type TLeaveProjectDTO = Omit; export enum ProjectUserMembershipTemporaryMode { Relative = "relative" diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 251e1c626..bde46dc7f 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -16,12 +16,20 @@ export const useCreateAccessApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateAccessPolicyDTO>({ - mutationFn: async ({ environment, projectSlug, approvals, approvers, name, secretPath, enforcementLevel }) => { + mutationFn: async ({ + environment, + projectSlug, + approvals, + approverUserIds, + name, + secretPath, + enforcementLevel + }) => { const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { environment, projectSlug, approvals, - approvers, + approverUserIds, secretPath, name, enforcementLevel diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index e17fe41d0..111c6a8c4 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -23,7 +23,14 @@ export type TAccessApprovalRequest = { id: string; policyId: string; privilegeId: string | null; - requestedBy: string; + requestedByUserId: string; + requestedByUser: { + email: string; + firstName?: string; + lastName?: string; + userId: string; + username: string; + }; createdAt: Date; updatedAt: Date; isTemporary: boolean; @@ -123,7 +130,7 @@ export type TCreateAccessPolicyDTO = { projectSlug: string; name?: string; environment: string; - approvers?: string[]; + approverUserIds?: string[]; approvals?: number; secretPath?: string; enforcementLevel?: EnforcementLevel; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 1e5661d1a..825d468da 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -83,6 +83,7 @@ export type TWorkspaceUser = { publicKey: string; }; projectId: string; + isGroupMember: boolean; project: { id: string; name: string; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 277499e9b..8f1b065da 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -377,14 +377,19 @@ export const useDeleteWsEnvironment = () => { }); }; -export const useGetWorkspaceUsers = (workspaceId: string) => { +export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?: boolean) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceUsers(workspaceId), queryFn: async () => { const { data: { users } } = await apiRequest.get<{ users: TWorkspaceUser[] }>( - `/api/v1/workspace/${workspaceId}/users` + `/api/v1/workspace/${workspaceId}/users`, + { + params: { + includeGroupMembers + } + } ); return users; }, diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index b57c0c3ab..93e906373 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -29,6 +29,7 @@ import { ProjectPermissionSub, useProjectPermission, useSubscription, + useUser, useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; @@ -47,7 +48,7 @@ import { queryClient } from "@app/reactQuery"; import { RequestAccessModal } from "./components/RequestAccessModal"; import { ReviewAccessRequestModal } from "./components/ReviewAccessModal"; -const generateRequestText = (request: TAccessApprovalRequest, membershipId: string) => { +const generateRequestText = (request: TAccessApprovalRequest, userId: string) => { const { isTemporary } = request; return ( @@ -63,7 +64,7 @@ const generateRequestText = (request: TAccessApprovalRequest, membershipId: stri
- {request.requestedBy === membershipId && ( + {request.requestedByUserId === userId && ( Requested By You @@ -81,11 +82,11 @@ export const AccessApprovalRequest = ({ projectId: string; }) => { const [selectedRequest, setSelectedRequest] = useState< - (TAccessApprovalRequest & { - user: TWorkspaceUser["user"] | null; - isRequestedByCurrentUser: boolean; - isApprover: boolean; - }) + | (TAccessApprovalRequest & { + user: TWorkspaceUser["user"] | null; + isRequestedByCurrentUser: boolean; + isApprover: boolean; + }) | null >(null); @@ -94,16 +95,19 @@ export const AccessApprovalRequest = ({ "reviewRequest", "upgradePlan" ] as const); - const { membership, permission } = useProjectPermission(); + const { permission } = useProjectPermission(); + const { user } = useUser(); const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); - const { data: members } = useGetWorkspaceUsers(projectId); + const { data: members } = useGetWorkspaceUsers(projectId, true); const membersGroupById = members?.reduce>( - (prev, curr) => ({ ...prev, [curr.id]: curr }), + (prev, curr) => ({ ...prev, [curr.user.id]: curr }), {} ); + console.log("membersGroupById", membersGroupById); + const [statusFilter, setStatusFilter] = useState<"open" | "close">("open"); const [requestedByFilter, setRequestedByFilter] = useState(undefined); const [envFilter, setEnvFilter] = useState(undefined); @@ -140,19 +144,18 @@ export const AccessApprovalRequest = ({ }, [requests, statusFilter, requestedByFilter, envFilter]); const generateRequestDetails = (request: TAccessApprovalRequest) => { - const isReviewedByUser = - request.reviewers.findIndex(({ member }) => member === membership.id) !== -1; + console.log(request); + + const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1; const isRejectedByAnyone = request.reviewers.some( ({ status }) => status === ApprovalStatus.REJECTED ); - const isApprover = request.policy.approvers.indexOf(membership.id || "") !== -1; + const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1; const isAccepted = request.isApproved; const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; - const isRequestedByCurrentUser = request.requestedBy === membership.id; + const isRequestedByCurrentUser = request.requestedByUserId === user.id; - const userReviewStatus = request.reviewers.find( - ({ member }) => member === membership.id - )?.status; + const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status; let displayData: { label: string; type: "primary" | "danger" | "success" } = { label: "", @@ -303,7 +306,7 @@ export const AccessApprovalRequest = ({ Select an author - {members?.map(({ user, id }) => ( + {members?.map(({ user: membershipUser, id }) => ( setRequestedByFilter((state) => (state === id ? undefined : id)) @@ -312,7 +315,7 @@ export const AccessApprovalRequest = ({ icon={requestedByFilter === id && } iconPos="right" > - {user.username} + {membershipUser.username} ))} @@ -341,22 +344,21 @@ export const AccessApprovalRequest = ({ tabIndex={0} onClick={() => { if ( - ( - !details.isApprover - || details.isReviewedByUser - || details.isRejectedByAnyone - || details.isAccepted - ) && !( - details.isSoftEnforcement - && details.isRequestedByCurrentUser - && !details.isAccepted + (!details.isApprover || + details.isReviewedByUser || + details.isRejectedByAnyone || + details.isAccepted) && + !( + details.isSoftEnforcement && + details.isRequestedByCurrentUser && + !details.isAccepted ) ) return; setSelectedRequest({ ...request, - user: membersGroupById?.[request.requestedBy].user!, + user: membersGroupById?.[request.requestedByUserId].user!, isRequestedByCurrentUser: details.isRequestedByCurrentUser, isApprover: details.isApprover }); @@ -373,7 +375,7 @@ export const AccessApprovalRequest = ({ if (evt.key === "Enter") { setSelectedRequest({ ...request, - user: membersGroupById?.[request.requestedBy].user!, + user: membersGroupById?.[request.requestedByUserId].user!, isRequestedByCurrentUser: details.isRequestedByCurrentUser, isApprover: details.isApprover }); @@ -385,16 +387,17 @@ export const AccessApprovalRequest = ({
- {generateRequestText(request, membership.id)} + {generateRequestText(request, user.id)}
- {membersGroupById?.[request.requestedBy]?.user && ( + {membersGroupById?.[request.requestedByUserId]?.user && ( <> Requested {formatDistance(new Date(request.createdAt), new Date())}{" "} - ago by {membersGroupById?.[request.requestedBy]?.user?.firstName}{" "} - {membersGroupById?.[request.requestedBy]?.user?.lastName} ( - {membersGroupById?.[request.requestedBy]?.user?.email}){" "} + ago by{" "} + {membersGroupById?.[request.requestedByUserId]?.user?.firstName}{" "} + {membersGroupById?.[request.requestedByUserId]?.user?.lastName} ( + {membersGroupById?.[request.requestedByUserId]?.user?.email}){" "} )}
diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index 2639f2351..7234c18ce 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -1,5 +1,10 @@ -import { useMemo,useState } from "react"; -import { faCheckCircle,faChevronDown, faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { useMemo, useState } from "react"; +import { + faCheckCircle, + faChevronDown, + faFileShield, + faPlus +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; @@ -32,7 +37,12 @@ import { useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useDeleteAccessApprovalPolicy, useDeleteSecretApprovalPolicy, useGetSecretApprovalPolicies, useGetWorkspaceUsers } from "@app/hooks/api"; +import { + useDeleteAccessApprovalPolicy, + useDeleteSecretApprovalPolicy, + useGetSecretApprovalPolicies, + useGetWorkspaceUsers +} from "@app/hooks/api"; import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries"; import { PolicyType } from "@app/hooks/api/policies/enums"; import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types"; @@ -45,27 +55,32 @@ interface IProps { } const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => { - const { data: accessPolicies, isLoading: isAccessPoliciesLoading } = useGetAccessApprovalPolicies({ - projectSlug: currentWorkspace?.slug as string, - options: { - enabled: - permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && - !!currentWorkspace?.slug + const { data: accessPolicies, isLoading: isAccessPoliciesLoading } = useGetAccessApprovalPolicies( + { + projectSlug: currentWorkspace?.slug as string, + options: { + enabled: + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && + !!currentWorkspace?.slug + } } - }); - const { data: secretPolicies, isLoading: isSecretPoliciesLoading } = useGetSecretApprovalPolicies({ - workspaceId: currentWorkspace?.id as string, - options: { - enabled: - permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && - !!currentWorkspace?.id + ); + const { data: secretPolicies, isLoading: isSecretPoliciesLoading } = useGetSecretApprovalPolicies( + { + workspaceId: currentWorkspace?.id as string, + options: { + enabled: + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && + !!currentWorkspace?.id + } } - }); + ); // merge data sorted by updatedAt const policies = [ - ...(accessPolicies?.map(policy => ({ ...policy, policyType: PolicyType.AccessPolicy })) || []), - ...(secretPolicies?.map(policy => ({ ...policy, policyType: PolicyType.ChangePolicy })) || []) + ...(accessPolicies?.map((policy) => ({ ...policy, policyType: PolicyType.AccessPolicy })) || + []), + ...(secretPolicies?.map((policy) => ({ ...policy, policyType: PolicyType.ChangePolicy })) || []) ].sort((a, b) => { return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); }); @@ -86,15 +101,16 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); - const { data: members } = useGetWorkspaceUsers(workspaceId); - const { policies, isLoading: isPoliciesLoading } = useApprovalPolicies(permission, currentWorkspace); + const { data: members } = useGetWorkspaceUsers(workspaceId, true); + const { policies, isLoading: isPoliciesLoading } = useApprovalPolicies( + permission, + currentWorkspace + ); const [filterType, setFilterType] = useState(null); const filteredPolicies = useMemo(() => { - return filterType - ? policies.filter(policy => policy.policyType === filterType) - : policies; + return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies; }, [policies, filterType]); const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); @@ -177,8 +193,10 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { @@ -194,14 +212,22 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { setFilterType(PolicyType.AccessPolicy)} - icon={filterType === PolicyType.AccessPolicy && } + icon={ + filterType === PolicyType.AccessPolicy && ( + + ) + } iconPos="right" > Access Policy setFilterType(PolicyType.ChangePolicy)} - icon={filterType === PolicyType.ChangePolicy && } + icon={ + filterType === PolicyType.ChangePolicy && ( + + ) + } iconPos="right" > Change Policy diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index c9eb61185..b9b0523ee 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -7,363 +7,378 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - FormControl, - Input, - Modal, - ModalContent, - Select, - SelectItem + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { policyDetails } from "@app/helpers/policies"; import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; import { - useCreateAccessApprovalPolicy, - useUpdateAccessApprovalPolicy + useCreateAccessApprovalPolicy, + useUpdateAccessApprovalPolicy } from "@app/hooks/api/accessApproval"; import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; import { TWorkspaceUser } from "@app/hooks/api/users/types"; type Props = { - isOpen?: boolean; - onToggle: (isOpen: boolean) => void; - members?: TWorkspaceUser[]; - projectSlug: string; - editValues?: TAccessApprovalPolicy; + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + projectSlug: string; + editValues?: TAccessApprovalPolicy; }; const formSchema = z -.object({ - environment: z.string(), - name: z.string().optional(), - secretPath: z.string().optional(), - approvals: z.number().min(1), - approvers: z.string().array().min(1), - policyType: z.nativeEnum(PolicyType), - enforcementLevel: z.nativeEnum(EnforcementLevel) -}) -.refine((data) => data.approvals <= data.approvers.length, { - path: ["approvals"], - message: "The number of approvals should be lower than the number of approvers." -}); + .object({ + environment: z.string(), + name: z.string().optional(), + secretPath: z.string().optional(), + approvals: z.number().min(1), + approverUserIds: z.string().array().min(1), + policyType: z.nativeEnum(PolicyType), + enforcementLevel: z.nativeEnum(EnforcementLevel) + }) + .refine((data) => data.approvals <= data.approverUserIds.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 = [], - projectSlug, - editValues + isOpen, + onToggle, + members = [], + projectSlug, + editValues }: Props) => { - const { - control, - handleSubmit, - reset, - watch, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(formSchema), - values: editValues ? { - ...editValues, - environment: editValues.environment.slug, - approvers: editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers - } : undefined - }); - const { currentWorkspace } = useWorkspace(); - - const environments = currentWorkspace?.environments || []; - const isEditMode = Boolean(editValues); - - useEffect(() => { - if (!isOpen || !isEditMode) reset({}); - }, [isOpen, isEditMode]); - - const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); - const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); - - const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy(); - const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy(); + const { + control, + handleSubmit, + reset, + watch, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues + ? { + ...editValues, + environment: editValues.environment.slug, + approverUserIds: + editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers + } + : undefined + }); + const { currentWorkspace } = useWorkspace(); - const policyName = policyDetails[watch("policyType")]?.name || "Policy"; - - const handleCreatePolicy = async (data: TFormSchema) => { - if (!projectSlug) return; - - try { - if (data.policyType === PolicyType.ChangePolicy) { - await createSecretApprovalPolicy({ - ...data, - workspaceId: currentWorkspace?.id || "" - }); - } else { - await createAccessApprovalPolicy({ - ...data, - projectSlug - }); - } - 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 (!projectSlug) return; - if (!editValues?.id) return; - - try { - if (data.policyType === PolicyType.ChangePolicy) { - await updateSecretApprovalPolicy({ - id: editValues?.id, - ...data, - workspaceId: currentWorkspace?.id || "" - }); - } else { - await updateAccessApprovalPolicy({ - id: editValues?.id, - ...data, - projectSlug - }); - } - 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); - } - }; + const environments = currentWorkspace?.environments || []; + const isEditMode = Boolean(editValues); - const formatEnforcementLevel = (level: EnforcementLevel) => { - if (level === EnforcementLevel.Hard) return "Hard"; - if (level === EnforcementLevel.Soft) return "Soft"; - return level; - }; - - return ( - - -
-
- ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - - - - - Select members that are allowed to approve requests - - {members.map(({ id, user }) => { - const userId = watch("policyType") === PolicyType.ChangePolicy ? user.id : id; - const isChecked = value?.includes(userId); - return ( - { - evt.preventDefault(); - onChange( - isChecked ? value?.filter((el: string) => el !== userId) : [...(value || []), userId] - ); - }} - key={`create-policy-members-${userId}`} - iconPos="right" - icon={isChecked && } - > - {user.username} - - ); - })} - - - - )} - /> - ( - - field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> - ( - - - - )} - /> -
- - -
- -
-
-
- ); + useEffect(() => { + if (!isOpen || !isEditMode) reset({}); + }, [isOpen, isEditMode]); + + const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); + const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); + + const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy(); + const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy(); + + const policyName = policyDetails[watch("policyType")]?.name || "Policy"; + + const handleCreatePolicy = async (data: TFormSchema) => { + if (!projectSlug) return; + + try { + if (data.policyType === PolicyType.ChangePolicy) { + await createSecretApprovalPolicy({ + ...data, + workspaceId: currentWorkspace?.id || "" + }); + } else { + await createAccessApprovalPolicy({ + ...data, + projectSlug + }); + } + 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 (!projectSlug) return; + if (!editValues?.id) return; + + try { + if (data.policyType === PolicyType.ChangePolicy) { + await updateSecretApprovalPolicy({ + id: editValues?.id, + ...data, + workspaceId: currentWorkspace?.id || "" + }); + } else { + await updateAccessApprovalPolicy({ + id: editValues?.id, + ...data, + projectSlug + }); + } + 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); + } + }; + + const formatEnforcementLevel = (level: EnforcementLevel) => { + if (level === EnforcementLevel.Hard) return "Hard"; + if (level === EnforcementLevel.Soft) return "Soft"; + return level; + }; + + return ( + + +
+
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + + + + + Select members that are allowed to approve requests + + {members.map(({ user }) => { + const { id: userId } = user; + const isChecked = value?.includes(userId); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== userId) + : [...(value || []), userId] + ); + }} + key={`create-policy-members-${userId}`} + iconPos="right" + icon={isChecked && } + > + {user.username} + + ); + })} + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+
+ ); }; -