From e0e21530e27f37a881e1a47d5d9ec69b2a2af2c0 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:11:51 +0200 Subject: [PATCH 01/27] Schemas --- backend/src/db/schemas/access-approval-policies-approvers.ts | 4 ++-- backend/src/db/schemas/access-approval-requests-reviewers.ts | 4 ++-- backend/src/db/schemas/access-approval-requests.ts | 4 ++-- backend/src/db/schemas/project-user-additional-privilege.ts | 5 +++-- 4 files changed, 9 insertions(+), 8 deletions(-) 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; From 2aeae616de17b70b99a23ac0f18ed868a76c3d54 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:12:06 +0200 Subject: [PATCH 02/27] Migration --- .../20240724101056_access-request-groups.ts | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 backend/src/db/migrations/20240724101056_access-request-groups.ts 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..82b2ca165 --- /dev/null +++ b/backend/src/db/migrations/20240724101056_access-request-groups.ts @@ -0,0 +1,253 @@ +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) tb.dropColumn("requestedBy"); + 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) tb.dropColumn("member"); + 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.raw(` + UPDATE ${TableName.ProjectUserAdditionalPrivilege} AS puap + SET + "userId" = pm."userId", + "projectId" = pm."projectId" + FROM ${TableName.ProjectMembership} AS pm + WHERE puap."projectMembershipId" = pm."id" + `); + + 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) => { + tb.dropColumn("projectMembershipId"); + }); + } +} + +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.raw(` + UPDATE ${TableName.ProjectUserAdditionalPrivilege} AS puap + SET "projectMembershipId" = pm."id" + FROM ${TableName.ProjectMembership} AS pm + WHERE puap."userId" = pm."userId" AND puap."projectId" = pm."projectId" + `); + + // Then, delete records where no matching project membership was found + await knex.raw(` + DELETE FROM ${TableName.ProjectUserAdditionalPrivilege} + WHERE "projectMembershipId" IS NULL + `); + + await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { + tb.dropColumn("userId"); + tb.dropColumn("projectId"); + + tb.uuid("projectMembershipId").notNullable().alter(); + }); + } + + // ---------- 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"); + } + }); + + 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`])) + }); + + 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.uuid("member").notNullable().alter(); + tb.dropColumn("reviewerUserId"); + }); + } + } +} From c497e19b998ee6e6a04d6701d5ac12a5ac8539d0 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:12:23 +0200 Subject: [PATCH 03/27] Routers --- .../v1/access-approval-policy-router.ts | 20 ++++++++++++++----- .../v1/access-approval-request-router.ts | 16 ++++++++++++--- 2 files changed, 28 insertions(+), 8 deletions(-) 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() }) } From c64334462f10ec6ce93b7eee2054686b1c3585e0 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:12:47 +0200 Subject: [PATCH 04/27] Access approval policy --- .../access-approval-policy-dal.ts | 78 ++++++++++++------- .../access-approval-policy-service.ts | 43 +++------- .../access-approval-policy-types.ts | 4 +- 3 files changed, 65 insertions(+), 60 deletions(-) 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; From d3c63b5699087faecd230882b06c8843dd76ba80 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:13:06 +0200 Subject: [PATCH 05/27] Access approval request --- .../access-approval-request-dal.ts | 124 +++++++++++++++--- .../access-approval-request-service.ts | 35 +++-- 2 files changed, 126 insertions(+), 33 deletions(-) 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, From a10c358f83b6dae9856e83fdcf94d8d4855b7c20 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:13:29 +0200 Subject: [PATCH 06/27] Feat: Access requests group support --- .../ee/services/permission/permission-dal.ts | 89 ++++++++++++++++--- 1 file changed, 78 insertions(+), 11 deletions(-) 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" }); From 4bf5381060dbc01e52db9e11513d543e2f1a61b9 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:13:48 +0200 Subject: [PATCH 07/27] Feat: Access requests group support --- ...oject-user-additional-privilege-service.ts | 38 +++++-- backend/src/server/routes/index.ts | 1 + .../src/server/routes/v1/project-router.ts | 9 ++ .../group-project/group-project-dal.ts | 106 +++++++++++++++++- .../project-membership-service.ts | 24 +++- .../project-membership-types.ts | 2 +- 6 files changed, 167 insertions(+), 13 deletions(-) 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/server/routes/index.ts b/backend/src/server/routes/index.ts index d49c7aace..10fdcc74c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -553,6 +553,7 @@ export const registerRoutes = async ( 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/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 8f87e8d55..b5e97e864 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -19,6 +19,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 +55,7 @@ type TProjectMembershipServiceFactoryDep = { projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; + groupProjectDAL: TGroupProjectDALFactory; }; export type TProjectMembershipServiceFactory = ReturnType; @@ -68,6 +70,7 @@ export const projectMembershipServiceFactory = ({ orgDAL, userDAL, userGroupMembershipDAL, + groupProjectDAL, projectDAL, projectKeyDAL, licenseService @@ -77,6 +80,7 @@ export const projectMembershipServiceFactory = ({ actor, actorOrgId, actorAuthMethod, + includeGroupMembers, projectId }: TGetProjectMembershipDTO) => { const { permission } = await permissionService.getProjectPermission( @@ -88,7 +92,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 ({ 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" From 3647943c80bb536db63368ba41bced95d95c3376 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:14:12 +0200 Subject: [PATCH 08/27] Feat: Access requests group support --- .../src/hooks/api/accessApproval/mutation.tsx | 12 +- .../src/hooks/api/accessApproval/types.ts | 11 +- frontend/src/hooks/api/users/types.ts | 1 + frontend/src/hooks/api/workspace/queries.tsx | 9 +- .../AccessApprovalRequest.tsx | 73 +- .../ApprovalPolicyList/ApprovalPolicyList.tsx | 82 +- .../components/AccessPolicyModal.tsx | 711 +++++++++--------- 7 files changed, 492 insertions(+), 407 deletions(-) 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..7bec3ccde 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -7,363 +7,398 @@ 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 { + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + console.log(data); + 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))} + /> + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+
+ ); }; - From c921c28185956bbcadc42d1d42c96fa2ad924ff8 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:27:26 +0200 Subject: [PATCH 09/27] Update AccessPolicyModal.tsx --- .../components/AccessPolicyModal.tsx | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index 7bec3ccde..b9b0523ee 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -107,26 +107,6 @@ export const AccessPolicyForm = ({ workspaceId: currentWorkspace?.id || "" }); } else { - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); - console.log(data); await createAccessApprovalPolicy({ ...data, projectSlug From c865d1284998d27a57bdae30be6f90e3b5eb8a87 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 25 Jul 2024 11:40:43 +0200 Subject: [PATCH 10/27] Update 20240724101056_access-request-groups.ts --- .../20240724101056_access-request-groups.ts | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/backend/src/db/migrations/20240724101056_access-request-groups.ts b/backend/src/db/migrations/20240724101056_access-request-groups.ts index 82b2ca165..d2c05b261 100644 --- a/backend/src/db/migrations/20240724101056_access-request-groups.ts +++ b/backend/src/db/migrations/20240724101056_access-request-groups.ts @@ -54,7 +54,13 @@ export async function up(knex: Knex): Promise { }); // drop old fields await knex.schema.alterTable(TableName.AccessApprovalRequest, (tb) => { - if (hasRequestedBy) tb.dropColumn("requestedBy"); + 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(); }); } @@ -79,7 +85,13 @@ export async function up(knex: Knex): Promise { }); // drop table await knex.schema.alterTable(TableName.AccessApprovalRequestReviewer, (tb) => { - if (hasMemberId) tb.dropColumn("member"); + 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(); }); } @@ -121,7 +133,11 @@ export async function up(knex: Knex): Promise { if (projectUserAdditionalPrivilegeHasProjectMembershipId) { await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { - tb.dropColumn("projectMembershipId"); + // DROP AT A LATER TIME + // tb.dropColumn("projectMembershipId"); + + // ADD ALLOW NULLABLE FOR NOW + tb.uuid("projectMembershipId").nullable().alter(); }); } } @@ -166,8 +182,13 @@ export async function down(knex: Knex): Promise { `); await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { - tb.dropColumn("userId"); - tb.dropColumn("projectId"); + // DROP AT A LATER TIME + // tb.dropColumn("userId"); + // tb.dropColumn("projectId"); + + // ADD ALLOW NULLABLE FOR NOW + tb.uuid("userId").nullable().alter(); + tb.string("projectId").nullable().alter(); tb.uuid("projectMembershipId").notNullable().alter(); }); @@ -194,7 +215,12 @@ export async function down(knex: Knex): Promise { .where("userId", knex.raw("??", [`${TableName.AccessApprovalPolicyApprover}.approverUserId`])) }); await knex.schema.alterTable(TableName.AccessApprovalPolicyApprover, (tb) => { - tb.dropColumn("approverUserId"); + // DROP AT A LATER TIME + // tb.dropColumn("approverUserId"); + + // ADD ALLOW NULLABLE FOR NOW + tb.uuid("approverUserId").nullable().alter(); + tb.uuid("approverId").notNullable().alter(); }); } @@ -221,7 +247,13 @@ export async function down(knex: Knex): Promise { }); await knex.schema.alterTable(TableName.AccessApprovalRequest, (tb) => { - if (hasRequestedByUserId) tb.dropColumn("requestedByUserId"); + if (hasRequestedByUserId) { + // DROP AT A LATER TIME + tb.dropColumn("requestedByUserId"); + + // ADD ALLOW NULLABLE FOR NOW + tb.uuid("requestedByUserId").nullable().alter(); + } if (hasRequestedBy) tb.uuid("requestedBy").notNullable().alter(); }); } @@ -246,7 +278,11 @@ export async function down(knex: Knex): Promise { }); await knex.schema.alterTable(TableName.AccessApprovalRequestReviewer, (tb) => { tb.uuid("member").notNullable().alter(); + + // DROP AT A LATER TIME tb.dropColumn("reviewerUserId"); + + // ADD ALLOW NULLABLE FOR NOW }); } } From 209004ec6d99650f2ab5d0085c49541b0ccf5a19 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 25 Jul 2024 12:57:12 +0200 Subject: [PATCH 11/27] fix: rollback access approval requests requestedBy --- .../20240724101056_access-request-groups.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/backend/src/db/migrations/20240724101056_access-request-groups.ts b/backend/src/db/migrations/20240724101056_access-request-groups.ts index d2c05b261..a74f746e6 100644 --- a/backend/src/db/migrations/20240724101056_access-request-groups.ts +++ b/backend/src/db/migrations/20240724101056_access-request-groups.ts @@ -246,6 +246,29 @@ export async function down(knex: Knex): Promise { .where("userId", knex.raw("??", [`${TableName.AccessApprovalRequest}.requestedByUserId`])) }); + // 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.raw(` + UPDATE ${TableName.AccessApprovalRequest} AS aar + SET "requestedBy" = pm."id" + FROM ${TableName.ProjectMembership} AS pm + WHERE aar."requestedByUserId" = pm."userId" AND aar."policyId" IN ( + SELECT "id" + FROM ${TableName.AccessApprovalPolicy} + WHERE "envId" IN ( + SELECT "id" + FROM ${TableName.Environment} + WHERE "projectId" = pm."projectId" + ) + ) + `); + // Then, delete records where no matching project membership was found + await knex.raw(` + DELETE FROM ${TableName.AccessApprovalRequest} + WHERE "requestedBy" IS NULL + `); + await knex.schema.alterTable(TableName.AccessApprovalRequest, (tb) => { if (hasRequestedByUserId) { // DROP AT A LATER TIME @@ -280,9 +303,10 @@ export async function down(knex: Knex): Promise { tb.uuid("member").notNullable().alter(); // DROP AT A LATER TIME - tb.dropColumn("reviewerUserId"); + // tb.dropColumn("reviewerUserId"); // ADD ALLOW NULLABLE FOR NOW + tb.uuid("reviewerUserId").nullable().alter(); }); } } From 1216d218c1309f64bac2d60fc234d49f9ca272be Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 25 Jul 2024 13:02:57 +0200 Subject: [PATCH 12/27] fix: rollback access approval requests requestedBy --- .../20240724101056_access-request-groups.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/db/migrations/20240724101056_access-request-groups.ts b/backend/src/db/migrations/20240724101056_access-request-groups.ts index a74f746e6..fd6f0548c 100644 --- a/backend/src/db/migrations/20240724101056_access-request-groups.ts +++ b/backend/src/db/migrations/20240724101056_access-request-groups.ts @@ -238,13 +238,13 @@ export async function down(knex: Knex): Promise { } }); - 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`])) - }); + // 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`])) + // }); // 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 From b717de4f786af1bd97ae6b4d3cddb7f5c29de4d4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 25 Jul 2024 15:01:28 +0200 Subject: [PATCH 13/27] Update 20240724101056_access-request-groups.ts --- .../20240724101056_access-request-groups.ts | 82 +++++++++---------- 1 file changed, 38 insertions(+), 44 deletions(-) diff --git a/backend/src/db/migrations/20240724101056_access-request-groups.ts b/backend/src/db/migrations/20240724101056_access-request-groups.ts index fd6f0548c..c59df947d 100644 --- a/backend/src/db/migrations/20240724101056_access-request-groups.ts +++ b/backend/src/db/migrations/20240724101056_access-request-groups.ts @@ -116,14 +116,17 @@ export async function up(knex: Knex): Promise { tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); }); - await knex.raw(` - UPDATE ${TableName.ProjectUserAdditionalPrivilege} AS puap - SET - "userId" = pm."userId", - "projectId" = pm."projectId" - FROM ${TableName.ProjectMembership} AS pm - WHERE puap."projectMembershipId" = pm."id" - `); + await knex(TableName.ProjectUserAdditionalPrivilege) + .update({ + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + userId: knex.ref("pm.userId"), + // eslint-disable-next-line + // @ts-ignore because generate schema happens after this + projectId: knex.ref("pm.projectId") + }) + .from(`${TableName.ProjectUserAdditionalPrivilege} as puap`) + .join(`${TableName.ProjectMembership} as pm`, "puap.projectMembershipId", "pm.id"); await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { tb.uuid("userId").notNullable().alter(); @@ -168,18 +171,25 @@ export async function down(knex: Knex): Promise { if (!hasProjectMembershipId) { // First, update records where a matching project membership exists - await knex.raw(` - UPDATE ${TableName.ProjectUserAdditionalPrivilege} AS puap - SET "projectMembershipId" = pm."id" - FROM ${TableName.ProjectMembership} AS pm - WHERE puap."userId" = pm."userId" AND puap."projectId" = pm."projectId" - `); + 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`])) + }); // Then, delete records where no matching project membership was found - await knex.raw(` - DELETE FROM ${TableName.ProjectUserAdditionalPrivilege} - WHERE "projectMembershipId" IS NULL - `); + await knex(TableName.AccessApprovalRequest).whereNull("projectMembershipId").delete(); + await knex(TableName.ProjectUserAdditionalPrivilege).whereNull("projectMembershipId").delete(); await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { // DROP AT A LATER TIME @@ -238,36 +248,20 @@ export async function down(knex: Knex): Promise { } }); - // 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`])) - // }); - // 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.raw(` - UPDATE ${TableName.AccessApprovalRequest} AS aar - SET "requestedBy" = pm."id" - FROM ${TableName.ProjectMembership} AS pm - WHERE aar."requestedByUserId" = pm."userId" AND aar."policyId" IN ( - SELECT "id" - FROM ${TableName.AccessApprovalPolicy} - WHERE "envId" IN ( - SELECT "id" - FROM ${TableName.Environment} - WHERE "projectId" = pm."projectId" - ) - ) - `); + + 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.raw(` - DELETE FROM ${TableName.AccessApprovalRequest} - WHERE "requestedBy" IS NULL - `); + await knex(TableName.AccessApprovalRequest).whereNull("requestedBy").delete(); await knex.schema.alterTable(TableName.AccessApprovalRequest, (tb) => { if (hasRequestedByUserId) { From ae5b57f69fd589e1c83dbb7897bedb47378dcba0 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 25 Jul 2024 19:37:40 +0200 Subject: [PATCH 14/27] Update 20240724101056_access-request-groups.ts --- .../20240724101056_access-request-groups.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/src/db/migrations/20240724101056_access-request-groups.ts b/backend/src/db/migrations/20240724101056_access-request-groups.ts index c59df947d..dd1f5d507 100644 --- a/backend/src/db/migrations/20240724101056_access-request-groups.ts +++ b/backend/src/db/migrations/20240724101056_access-request-groups.ts @@ -126,7 +126,8 @@ export async function up(knex: Knex): Promise { projectId: knex.ref("pm.projectId") }) .from(`${TableName.ProjectUserAdditionalPrivilege} as puap`) - .join(`${TableName.ProjectMembership} as pm`, "puap.projectMembershipId", "pm.id"); + .join(`${TableName.ProjectMembership} as pm`, "puap.projectMembershipId", "pm.id") + .whereNotNull("puap.projectMembershipId"); await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { tb.uuid("userId").notNullable().alter(); @@ -187,10 +188,6 @@ export async function down(knex: Knex): Promise { .where("userId", knex.raw("??", [`${TableName.SecretApprovalRequest}.userId`])) }); - // Then, delete records where no matching project membership was found - await knex(TableName.AccessApprovalRequest).whereNull("projectMembershipId").delete(); - await knex(TableName.ProjectUserAdditionalPrivilege).whereNull("projectMembershipId").delete(); - await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { // DROP AT A LATER TIME // tb.dropColumn("userId"); @@ -204,6 +201,10 @@ export async function down(knex: Knex): Promise { }); } + // 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"); From 65db91d4918eaeac707a51b05f85f36d1c708ee1 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 25 Jul 2024 20:07:34 +0200 Subject: [PATCH 15/27] Update 20240724101056_access-request-groups.ts --- .../20240724101056_access-request-groups.ts | 40 ++++++------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/backend/src/db/migrations/20240724101056_access-request-groups.ts b/backend/src/db/migrations/20240724101056_access-request-groups.ts index dd1f5d507..12174e2b5 100644 --- a/backend/src/db/migrations/20240724101056_access-request-groups.ts +++ b/backend/src/db/migrations/20240724101056_access-request-groups.ts @@ -116,18 +116,21 @@ export async function up(knex: Knex): Promise { tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); }); + const membershipQuery = knex(TableName.ProjectMembership) + .select("userId", "projectId") + .whereRaw(`${TableName.ProjectMembership}.id = ${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId`) + .limit(1); + await knex(TableName.ProjectUserAdditionalPrivilege) .update({ // eslint-disable-next-line // @ts-ignore because generate schema happens after this - userId: knex.ref("pm.userId"), + userId: membershipQuery.select("userId"), // eslint-disable-next-line // @ts-ignore because generate schema happens after this - projectId: knex.ref("pm.projectId") + projectId: membershipQuery.select("projectId") }) - .from(`${TableName.ProjectUserAdditionalPrivilege} as puap`) - .join(`${TableName.ProjectMembership} as pm`, "puap.projectMembershipId", "pm.id") - .whereNotNull("puap.projectMembershipId"); + .whereNotNull("projectMembershipId"); await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { tb.uuid("userId").notNullable().alter(); @@ -189,13 +192,8 @@ export async function down(knex: Knex): Promise { }); await knex.schema.alterTable(TableName.ProjectUserAdditionalPrivilege, (tb) => { - // DROP AT A LATER TIME - // tb.dropColumn("userId"); - // tb.dropColumn("projectId"); - - // ADD ALLOW NULLABLE FOR NOW - tb.uuid("userId").nullable().alter(); - tb.string("projectId").nullable().alter(); + tb.dropColumn("userId"); + tb.dropColumn("projectId"); tb.uuid("projectMembershipId").notNullable().alter(); }); @@ -226,11 +224,7 @@ export async function down(knex: Knex): Promise { .where("userId", knex.raw("??", [`${TableName.AccessApprovalPolicyApprover}.approverUserId`])) }); await knex.schema.alterTable(TableName.AccessApprovalPolicyApprover, (tb) => { - // DROP AT A LATER TIME - // tb.dropColumn("approverUserId"); - - // ADD ALLOW NULLABLE FOR NOW - tb.uuid("approverUserId").nullable().alter(); + tb.dropColumn("approverUserId"); tb.uuid("approverId").notNullable().alter(); }); @@ -266,11 +260,7 @@ export async function down(knex: Knex): Promise { await knex.schema.alterTable(TableName.AccessApprovalRequest, (tb) => { if (hasRequestedByUserId) { - // DROP AT A LATER TIME tb.dropColumn("requestedByUserId"); - - // ADD ALLOW NULLABLE FOR NOW - tb.uuid("requestedByUserId").nullable().alter(); } if (hasRequestedBy) tb.uuid("requestedBy").notNullable().alter(); }); @@ -295,13 +285,9 @@ export async function down(knex: Knex): Promise { .where("userId", knex.raw("??", [`${TableName.AccessApprovalRequestReviewer}.reviewerUserId`])) }); await knex.schema.alterTable(TableName.AccessApprovalRequestReviewer, (tb) => { + tb.dropColumn("reviewerUserId"); + tb.uuid("member").notNullable().alter(); - - // DROP AT A LATER TIME - // tb.dropColumn("reviewerUserId"); - - // ADD ALLOW NULLABLE FOR NOW - tb.uuid("reviewerUserId").nullable().alter(); }); } } From 4323f6fa8f202967b06a6afb4fa95b29355458e8 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 25 Jul 2024 20:20:53 +0200 Subject: [PATCH 16/27] Update 20240724101056_access-request-groups.ts --- .../20240724101056_access-request-groups.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/db/migrations/20240724101056_access-request-groups.ts b/backend/src/db/migrations/20240724101056_access-request-groups.ts index 12174e2b5..1576c201c 100644 --- a/backend/src/db/migrations/20240724101056_access-request-groups.ts +++ b/backend/src/db/migrations/20240724101056_access-request-groups.ts @@ -116,19 +116,19 @@ export async function up(knex: Knex): Promise { tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); }); - const membershipQuery = knex(TableName.ProjectMembership) - .select("userId", "projectId") - .whereRaw(`${TableName.ProjectMembership}.id = ${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId`) - .limit(1); - await knex(TableName.ProjectUserAdditionalPrivilege) .update({ // eslint-disable-next-line // @ts-ignore because generate schema happens after this - userId: membershipQuery.select("userId"), + 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: membershipQuery.select("projectId") + projectId: knex(TableName.ProjectMembership) + .select("projectId") + .where("id", knex.raw("??", [`${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId`])) }) .whereNotNull("projectMembershipId"); From 1f3896231a56d086ec01b633b39c390e83eca635 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 26 Jul 2024 23:28:23 +0200 Subject: [PATCH 17/27] fix: remove privileges when user loses access to project/org --- backend/src/server/routes/index.ts | 2 + backend/src/services/org/org-fns.ts | 10 +++++ backend/src/services/org/org-service.ts | 4 ++ .../project-membership-service.ts | 38 ++++++++++++++++--- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 10fdcc74c..769300241 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -477,6 +477,7 @@ export const registerRoutes = async ( orgDAL, incidentContactDAL, tokenService, + projectUserAdditionalPrivilegeDAL, projectDAL, projectMembershipDAL, orgMembershipDAL, @@ -549,6 +550,7 @@ export const registerRoutes = async ( projectBotDAL, orgDAL, userDAL, + projectUserAdditionalPrivilegeDAL, userGroupMembershipDAL, smtpService, projectKeyDAL, 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 b5e97e864..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"; @@ -55,6 +56,7 @@ type TProjectMembershipServiceFactoryDep = { projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; + projectUserAdditionalPrivilegeDAL: Pick; groupProjectDAL: TGroupProjectDALFactory; }; @@ -68,6 +70,7 @@ export const projectMembershipServiceFactory = ({ projectRoleDAL, projectBotDAL, orgDAL, + projectUserAdditionalPrivilegeDAL, userDAL, userGroupMembershipDAL, groupProjectDAL, @@ -524,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, @@ -586,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" }); From c46e4d7fc13fc5485fab34b977e1272ecba5edb4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 26 Jul 2024 23:32:15 +0200 Subject: [PATCH 18/27] fix: scim cleanup --- backend/src/ee/services/scim/scim-service.ts | 4 ++++ backend/src/server/routes/index.ts | 1 + 2 files changed, 5 insertions(+) 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 769300241..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, From e970cc0f478aa48b303279a05e236ecfa438da32 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 20 Aug 2024 21:41:02 +0400 Subject: [PATCH 19/27] Fix: Error notification when user does not have access to read from certain environments --- frontend/src/hooks/api/secrets/constants.ts | 1 + frontend/src/hooks/api/secrets/queries.tsx | 13 ++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 frontend/src/hooks/api/secrets/constants.ts diff --git a/frontend/src/hooks/api/secrets/constants.ts b/frontend/src/hooks/api/secrets/constants.ts new file mode 100644 index 000000000..97280c902 --- /dev/null +++ b/frontend/src/hooks/api/secrets/constants.ts @@ -0,0 +1 @@ +export const ERROR_NOT_ALLOWED_READ_SECRETS = "You are not allowed to read on secrets"; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 8d0961ca5..2a24dda47 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -7,6 +7,7 @@ import { createNotification } from "@app/components/notifications"; import { apiRequest } from "@app/config/request"; import { useToggle } from "@app/hooks/useToggle"; +import { ERROR_NOT_ALLOWED_READ_SECRETS } from "./constants"; import { GetSecretVersionsDTO, SecretType, @@ -135,11 +136,13 @@ export const useGetProjectSecretsAllEnv = ({ onError: (error: unknown) => { if (axios.isAxiosError(error) && !isErrorHandled) { const serverResponse = error.response?.data as { message: string }; - createNotification({ - title: "Error fetching secrets", - type: "error", - text: serverResponse.message - }); + if (serverResponse.message !== ERROR_NOT_ALLOWED_READ_SECRETS) { + createNotification({ + title: "Error fetching secrets", + type: "error", + text: serverResponse.message + }); + } setIsErrorHandled.on(); } From 61370cc6b2b6ba32858802933ab342b323d753e2 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 20 Aug 2024 21:44:41 -0700 Subject: [PATCH 20/27] Finish allow installing intermediate CA with external parent CA --- .../certificate-authority-service.ts | 23 +- .../src/components/v2/TextArea/TextArea.tsx | 6 +- frontend/src/views/Project/CaPage/CaPage.tsx | 1 - .../CaPage/components/CaDetailsSection.tsx | 57 ++-- .../CaInstallCertModal/CaInstallCertModal.tsx | 293 ++---------------- .../ExternalCaInstallForm.tsx | 172 ++++++++++ .../InternalCaInstallForm.tsx | 236 ++++++++++++++ 7 files changed, 483 insertions(+), 305 deletions(-) create mode 100644 frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/ExternalCaInstallForm.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/InternalCaInstallForm.tsx diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 32dbc8fd0..b2ee04c4c 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -368,7 +368,6 @@ export const certificateAuthorityServiceFactory = ({ ); if (ca.type === CaType.ROOT) throw new BadRequestError({ message: "Root CA cannot generate CSR" }); - if (ca.activeCaCertId) throw new BadRequestError({ message: "CA already has a certificate installed" }); const { caPrivateKey, caPublicKey } = await getCaCredentials({ caId, @@ -407,7 +406,8 @@ export const certificateAuthorityServiceFactory = ({ /** * Renew certificate for CA with id [caId] - * Note: Currently implements CA renewal with same key-pair only + * Note 1: This CA renewal method is only applicable to CAs with internal parent CAs + * Note 2: Currently implements CA renewal with same key-pair only */ const renewCaCert = async ({ caId, notAfter, actorId, actorAuthMethod, actor, actorOrgId }: TRenewCaCertDTO) => { const ca = await certificateAuthorityDAL.findById(caId); @@ -888,9 +888,9 @@ export const certificateAuthorityServiceFactory = ({ }; /** - * Import certificate for (un-installed) CA with id [caId]. + * Import certificate for CA with id [caId]. * Note: Can be used to import an external certificate and certificate chain - * to be installed into the CA. + * to be into an installed or uninstalled CA. */ const importCertToCa = async ({ caId, @@ -917,7 +917,18 @@ export const certificateAuthorityServiceFactory = ({ ProjectPermissionSub.CertificateAuthorities ); - if (ca.activeCaCertId) throw new BadRequestError({ message: "CA has already imported a certificate" }); + if (ca.parentCaId) { + /** + * re-evaluate in the future if we should allow users to import a new CA certificate for an intermediate + * CA chained to an internal parent CA. Doing so would allow users to re-chain the CA to a different + * internal CA. + */ + throw new BadRequestError({ + message: "Cannot import certificate to intermediate CA chained to internal parent CA" + }); + } + + const caCert = ca.activeCaCertId ? await certificateAuthorityCertDAL.findById(ca.activeCaCertId) : undefined; const certObj = new x509.X509Certificate(certificate); const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; @@ -988,7 +999,7 @@ export const certificateAuthorityServiceFactory = ({ caId: ca.id, encryptedCertificate, encryptedCertificateChain, - version: 1, + version: caCert ? caCert.version + 1 : 1, caSecretId: caSecret.id }, tx diff --git a/frontend/src/components/v2/TextArea/TextArea.tsx b/frontend/src/components/v2/TextArea/TextArea.tsx index 3f75f5a43..a1e71edd2 100644 --- a/frontend/src/components/v2/TextArea/TextArea.tsx +++ b/frontend/src/components/v2/TextArea/TextArea.tsx @@ -11,7 +11,7 @@ type Props = { }; const textAreaVariants = cva( - "textarea w-full p-2 focus:ring-2 ring-primary-800 outline-none border border-solid text-gray-400 font-inter placeholder-gray-500 placeholder-opacity-50", + "textarea w-full p-2 focus:ring-2 ring-primary-800 outline-none border text-gray-400 font-inter placeholder-gray-500 placeholder-opacity-50", { variants: { size: { @@ -25,13 +25,13 @@ const textAreaVariants = cva( false: "" }, variant: { - filled: ["bg-bunker-800", "text-gray-400"], + filled: ["bg-mineshaft-900", "text-gray-400"], outline: ["bg-transparent"], plain: "bg-transparent outline-none" }, isError: { true: "focus:ring-red/50 placeholder-red-300 border-red", - false: "focus:ring-primary/50 border-mineshaft-400" + false: "focus:ring-primary-400/50 focus:ring-1 border-mineshaft-500" } }, compoundVariants: [ diff --git a/frontend/src/views/Project/CaPage/CaPage.tsx b/frontend/src/views/Project/CaPage/CaPage.tsx index 7edc99af9..e0bdaeb64 100644 --- a/frontend/src/views/Project/CaPage/CaPage.tsx +++ b/frontend/src/views/Project/CaPage/CaPage.tsx @@ -22,7 +22,6 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { CaModal } from "@app/views/Project/CertificatesPage/components/CaTab/components/CaModal"; import { CaInstallCertModal } from "../CertificatesPage/components/CaTab/components/CaInstallCertModal"; -import { TabSections } from "../Types"; import { CaCertificatesSection, CaDetailsSection, CaRenewalModal } from "./components"; export const CaPage = withProjectPermission( diff --git a/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx b/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx index eb8ccf86f..94eae3e6b 100644 --- a/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx +++ b/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx @@ -6,7 +6,7 @@ import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, IconButton, Tooltip } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useTimedReset } from "@app/hooks"; -import { CaStatus, useGetCaById } from "@app/hooks/api"; +import { CaStatus, CaType, useGetCaById } from "@app/hooks/api"; import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants"; import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -35,6 +35,10 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {

CA Details

+
+

CA Type

+

{caTypeToNameMap[ca.type]}

+

CA ID

@@ -56,26 +60,30 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
- {ca.parentCaId && ( + {ca.type === CaType.INTERMEDIATE && ca.status !== CaStatus.PENDING_CERTIFICATE && (

Parent CA ID

-

{ca.parentCaId}

-
- - { - navigator.clipboard.writeText(ca.parentCaId as string); - setCopyTextParentId("Copied"); - }} - > - - - -
+

+ {ca.parentCaId ? ca.parentCaId : "N/A - External Parent CA"} +

+ {ca.parentCaId && ( +
+ + { + navigator.clipboard.writeText(ca.parentCaId as string); + setCopyTextParentId("Copied"); + }} + > + + + +
+ )}
)} @@ -83,10 +91,6 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {

Friendly Name

{ca.friendlyName}

-
-

CA Type

-

{caTypeToNameMap[ca.type]}

-

Status

{caStatusToNameMap[ca.status]}

@@ -124,6 +128,15 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { colorSchema="primary" type="submit" onClick={() => { + if (ca.type === CaType.INTERMEDIATE && !ca.parentCaId) { + // intermediate CA with external parent CA + handlePopUpOpen("installCaCert", { + caId, + isParentCaExternal: true + }); + return; + } + handlePopUpOpen("renewCa", { caId }); diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx index 4d2bcb243..1095e0d3e 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx @@ -1,53 +1,10 @@ import { useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { format } from "date-fns"; -import { z } from "zod"; -import { createNotification } from "@app/components/notifications"; -import { - // DatePicker, - Button, - FormControl, - Input, - Modal, - ModalContent, - Select, - SelectItem -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { - CaStatus, - useGetCaById, - useGetCaCsr, - useImportCaCertificate, - useListWorkspaceCas, - useSignIntermediate -} from "@app/hooks/api"; -import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const isValidDate = (dateString: string) => { - const date = new Date(dateString); - return !Number.isNaN(date.getTime()); -}; - -const getMiddleDate = (date1: Date, date2: Date) => { - const timestamp1 = date1.getTime(); - const timestamp2 = date2.getTime(); - - const middleTimestamp = (timestamp1 + timestamp2) / 2; - - return new Date(middleTimestamp); -}; - -const schema = z.object({ - parentCaId: z.string(), - notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), - maxPathLength: z.string() -}); - -export type FormData = z.infer; +import { ExternalCaInstallForm } from "./ExternalCaInstallForm"; +import { InternalCaInstallForm } from "./InternalCaInstallForm"; type Props = { popUp: UsePopUpState<["installCaCert"]>; @@ -60,234 +17,23 @@ enum ParentCaType { } export const CaInstallCertModal = ({ popUp, handlePopUpToggle }: Props) => { - const [parentCaType] = useState(ParentCaType.Internal); - const { currentWorkspace } = useWorkspace(); - const caId = (popUp?.installCaCert?.data as { caId: string })?.caId || ""; - - // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); - const { data: cas } = useListWorkspaceCas({ - projectSlug: currentWorkspace?.slug ?? "", - status: CaStatus.ACTIVE - }); - const { data: ca } = useGetCaById(caId); - const { data: csr } = useGetCaCsr(caId); - - const { mutateAsync: signIntermediate } = useSignIntermediate(); - const { mutateAsync: importCaCertificate } = useImportCaCertificate(); - - const { - control, - handleSubmit, - reset, - formState: { isSubmitting }, - setValue, - watch - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - maxPathLength: "0" - } - }); + const popupData = popUp?.installCaCert?.data; + const caId = popupData?.caId || ""; + const isParentCaExternal = popupData?.isParentCaExternal || false; + const [parentCaType, setParentCaType] = useState(ParentCaType.Internal); useEffect(() => { - if (cas?.length) { - setValue("parentCaId", cas[0].id); + if (popupData?.isParentCaExternal) { + setParentCaType(ParentCaType.External); } - }, [cas, setValue]); - - const parentCaId = watch("parentCaId"); - const { data: parentCa } = useGetCaById(parentCaId); - - useEffect(() => { - if (parentCa?.maxPathLength) { - setValue( - "maxPathLength", - (parentCa.maxPathLength === -1 ? 3 : parentCa.maxPathLength - 1).toString() - ); - } - - if (parentCa?.notAfter) { - const parentCaNotAfter = new Date(parentCa.notAfter); - const middleDate = getMiddleDate(new Date(), parentCaNotAfter); - setValue("notAfter", format(middleDate, "yyyy-MM-dd")); - } - }, [parentCa]); - - const onFormSubmit = async ({ notAfter, maxPathLength }: FormData) => { - try { - if (!csr || !caId || !currentWorkspace?.slug) return; - - const { certificate, certificateChain } = await signIntermediate({ - caId: parentCaId, - csr, - maxPathLength: Number(maxPathLength), - notAfter, - notBefore: new Date().toISOString() - }); - - await importCaCertificate({ - caId, - projectSlug: currentWorkspace?.slug, - certificate, - certificateChain - }); - - reset(); - - createNotification({ - text: "Successfully installed certificate for CA", - type: "success" - }); - handlePopUpToggle("installCaCert", false); - } catch (err) { - createNotification({ - text: "Failed to install certificate for CA", - type: "error" - }); - } - }; - - function generatePathLengthOpts(parentCaMaxPathLength: number): number[] { - if (parentCaMaxPathLength === -1) { - return [-1, 0, 1, 2, 3]; - } - - return Array.from({ length: parentCaMaxPathLength }, (_, index) => index); - } + }, [popupData]); const renderForm = (parentCaTypeInput: ParentCaType) => { switch (parentCaTypeInput) { case ParentCaType.Internal: - return ( -
- ( - - - - )} - /> - {/* { - return ( - - { - onChange(date); - setIsStartDatePickerOpen(false); - }} - popUpProps={{ - open: isStartDatePickerOpen, - onOpenChange: setIsStartDatePickerOpen - }} - popUpContentProps={{}} - /> - - ); - }} - /> */} - ( - - - - )} - /> - ( - - - - )} - /> -
- - -
- - ); + return ; default: - return
External TODO
; + return ; } }; @@ -296,31 +42,32 @@ export const CaInstallCertModal = ({ popUp, handlePopUpToggle }: Props) => { isOpen={popUp?.installCaCert?.isOpen} onOpenChange={(isOpen) => { handlePopUpToggle("installCaCert", isOpen); - reset(); }} > - - {/* + + - */} + {renderForm(parentCaType)} diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/ExternalCaInstallForm.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/ExternalCaInstallForm.tsx new file mode 100644 index 000000000..d84b11f59 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/ExternalCaInstallForm.tsx @@ -0,0 +1,172 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import FileSaver from "file-saver"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, IconButton,TextArea, Tooltip } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { useGetCaCsr, useImportCaCertificate } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + certificate: z.string().min(1), + certificateChain: z.string().min(1) +}); + +export type FormData = z.infer; + +type Props = { + caId: string; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void; +}; + +export const ExternalCaInstallForm = ({ caId, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const [copyTextCaCsr, isCopyingCaCsr, setCopyTextCaCsr] = useTimedReset({ + initialState: "Copy to clipboard" + }); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const { data: csr } = useGetCaCsr(caId); + const { mutateAsync: importCaCertificate } = useImportCaCertificate(); + + useEffect(() => { + reset(); + }, []); + + const onFormSubmit = async ({ certificate, certificateChain }: FormData) => { + try { + if (!csr || !caId || !currentWorkspace?.slug) return; + + await importCaCertificate({ + caId, + projectSlug: currentWorkspace?.slug, + certificate, + certificateChain + }); + + reset(); + + createNotification({ + text: "Successfully installed certificate for CA", + type: "success" + }); + handlePopUpToggle("installCaCert", false); + } catch (err) { + createNotification({ + text: "Failed to install certificate for CA", + type: "error" + }); + } + }; + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + + return ( +
+ {csr && ( + <> +
+

CSR for this CA

+
+ + { + navigator.clipboard.writeText(csr); + setCopyTextCaCsr("Copied"); + }} + > + + + + + { + downloadTxtFile("csr.pem", csr); + }} + > + + + +
+
+
+

{csr}

+
+ + )} + ( + +