diff --git a/backend/package-lock.json b/backend/package-lock.json index 1b9a3e6b6..27ee940d3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -29,7 +29,7 @@ "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/x509": "^1.10.0", + "@peculiar/x509": "^1.12.1", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "1.1.0", "@team-plain/typescript-sdk": "^4.6.1", @@ -5041,9 +5041,9 @@ } }, "node_modules/@peculiar/x509": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.10.0.tgz", - "integrity": "sha512-gdH6H8gWjAYoM4Yr6wPnRbzU77nU7xq/jipqYyyv5/AHTrulN2Z5DlnOSq9jjKrB+Ya0D6YJ2cGGtwkWDK75jA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.12.1.tgz", + "integrity": "sha512-2T9t2viNP9m20mky50igPTpn2ByhHl5NlT6wW4Tp4BejQaQ5XDNZgfsabYwYysLXhChABlgtTCpp2gM3JBZRKA==", "dependencies": { "@peculiar/asn1-cms": "^2.3.8", "@peculiar/asn1-csr": "^2.3.8", diff --git a/backend/package.json b/backend/package.json index 1a298554b..043394769 100644 --- a/backend/package.json +++ b/backend/package.json @@ -126,7 +126,7 @@ "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/x509": "^1.10.0", + "@peculiar/x509": "^1.12.1", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "1.1.0", "@team-plain/typescript-sdk": "^4.6.1", diff --git a/backend/src/db/migrations/20240821212643_crl-ca-secret-binding.ts b/backend/src/db/migrations/20240821212643_crl-ca-secret-binding.ts new file mode 100644 index 000000000..eee243714 --- /dev/null +++ b/backend/src/db/migrations/20240821212643_crl-ca-secret-binding.ts @@ -0,0 +1,36 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateAuthorityCrl)) { + const hasCaSecretIdColumn = await knex.schema.hasColumn(TableName.CertificateAuthorityCrl, "caSecretId"); + if (!hasCaSecretIdColumn) { + await knex.schema.alterTable(TableName.CertificateAuthorityCrl, (t) => { + t.uuid("caSecretId").nullable(); + t.foreign("caSecretId").references("id").inTable(TableName.CertificateAuthoritySecret).onDelete("CASCADE"); + }); + + await knex.raw(` + UPDATE "${TableName.CertificateAuthorityCrl}" crl + SET "caSecretId" = ( + SELECT sec.id + FROM "${TableName.CertificateAuthoritySecret}" sec + WHERE sec."caId" = crl."caId" + ) + `); + + await knex.schema.alterTable(TableName.CertificateAuthorityCrl, (t) => { + t.uuid("caSecretId").notNullable().alter(); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateAuthorityCrl)) { + await knex.schema.alterTable(TableName.CertificateAuthorityCrl, (t) => { + t.dropColumn("caSecretId"); + }); + } +} diff --git a/backend/src/db/schemas/access-approval-requests-reviewers.ts b/backend/src/db/schemas/access-approval-requests-reviewers.ts index de9489288..a209df206 100644 --- a/backend/src/db/schemas/access-approval-requests-reviewers.ts +++ b/backend/src/db/schemas/access-approval-requests-reviewers.ts @@ -9,6 +9,7 @@ import { TImmutableDBKeys } from "./models"; export const AccessApprovalRequestsReviewersSchema = z.object({ id: z.string().uuid(), + member: z.string().uuid().nullable().optional(), status: z.string(), requestId: z.string().uuid(), createdAt: z.date(), diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts index 5102c0eae..0b20202f5 100644 --- a/backend/src/db/schemas/access-approval-requests.ts +++ b/backend/src/db/schemas/access-approval-requests.ts @@ -11,6 +11,7 @@ export const AccessApprovalRequestsSchema = z.object({ id: z.string().uuid(), policyId: z.string().uuid(), privilegeId: z.string().uuid().nullable().optional(), + requestedBy: z.string().uuid().nullable().optional(), isTemporary: z.boolean(), temporaryRange: z.string().nullable().optional(), permissions: z.unknown(), diff --git a/backend/src/db/schemas/certificate-authority-crl.ts b/backend/src/db/schemas/certificate-authority-crl.ts index 204a0c60c..3d63be5d8 100644 --- a/backend/src/db/schemas/certificate-authority-crl.ts +++ b/backend/src/db/schemas/certificate-authority-crl.ts @@ -14,7 +14,8 @@ export const CertificateAuthorityCrlSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), caId: z.string().uuid(), - encryptedCrl: zodBuffer + encryptedCrl: zodBuffer, + caSecretId: z.string().uuid() }); export type TCertificateAuthorityCrl = 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 bd69f1484..e657fc945 100644 --- a/backend/src/db/schemas/project-user-additional-privilege.ts +++ b/backend/src/db/schemas/project-user-additional-privilege.ts @@ -10,6 +10,7 @@ import { TImmutableDBKeys } from "./models"; export const ProjectUserAdditionalPrivilegeSchema = z.object({ id: z.string().uuid(), slug: z.string(), + projectMembershipId: z.string().uuid().nullable().optional(), isTemporary: z.boolean().default(false), temporaryMode: z.string().nullable().optional(), temporaryRange: z.string().nullable().optional(), diff --git a/backend/src/ee/routes/v1/certificate-authority-crl-router.ts b/backend/src/ee/routes/v1/certificate-authority-crl-router.ts index 10792f508..468981c0e 100644 --- a/backend/src/ee/routes/v1/certificate-authority-crl-router.ts +++ b/backend/src/ee/routes/v1/certificate-authority-crl-router.ts @@ -1,86 +1,31 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ import { z } from "zod"; -import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +import { CA_CRLS } from "@app/lib/api-docs"; import { readLimit } from "@app/server/config/rateLimiter"; -import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { AuthMode } from "@app/services/auth/auth-type"; export const registerCaCrlRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:caId/crl", + url: "/:crlId", config: { rateLimit: readLimit }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - description: "Get CRL of the CA", + description: "Get CRL in DER format", params: z.object({ - caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CRL.caId) + crlId: z.string().trim().describe(CA_CRLS.GET.crlId) }), response: { - 200: z.object({ - crl: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CRL.crl) - }) + 200: z.instanceof(Buffer) } }, - handler: async (req) => { - const { crl, ca } = await server.services.certificateAuthorityCrl.getCaCrl({ - caId: req.params.caId, - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId - }); + handler: async (req, res) => { + const { crl } = await server.services.certificateAuthorityCrl.getCrlById(req.params.crlId); - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: ca.projectId, - event: { - type: EventType.GET_CA_CRL, - metadata: { - caId: ca.id, - dn: ca.dn - } - } - }); + res.header("Content-Type", "application/pkix-crl"); - return { - crl - }; + return Buffer.from(crl); } }); - - // server.route({ - // method: "GET", - // url: "/:caId/crl/rotate", - // config: { - // rateLimit: writeLimit - // }, - // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - // schema: { - // description: "Rotate CRL of the CA", - // params: z.object({ - // caId: z.string().trim() - // }), - // response: { - // 200: z.object({ - // message: z.string() - // }) - // } - // }, - // handler: async (req) => { - // await server.services.certificateAuthority.rotateCaCrl({ - // caId: req.params.caId, - // actor: req.permission.type, - // actorId: req.permission.id, - // actorAuthMethod: req.permission.authMethod, - // actorOrgId: req.permission.orgId - // }); - // return { - // message: "Successfully rotated CA CRL" - // }; - // } - // }); }; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 1892bd5e8..961e06949 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -61,7 +61,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register( async (pkiRouter) => { - await pkiRouter.register(registerCaCrlRouter, { prefix: "/ca" }); + await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" }); }, { prefix: "/pki" } ); diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 52972d942..981b3777e 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -137,7 +137,7 @@ export enum EventType { GET_CA_CERT = "get-certificate-authority-cert", SIGN_INTERMEDIATE = "sign-intermediate", IMPORT_CA_CERT = "import-certificate-authority-cert", - GET_CA_CRL = "get-certificate-authority-crl", + GET_CA_CRLS = "get-certificate-authority-crls", ISSUE_CERT = "issue-cert", SIGN_CERT = "sign-cert", GET_CERT = "get-cert", @@ -1166,8 +1166,8 @@ interface ImportCaCert { }; } -interface GetCaCrl { - type: EventType.GET_CA_CRL; +interface GetCaCrls { + type: EventType.GET_CA_CRLS; metadata: { caId: string; dn: string; @@ -1544,7 +1544,7 @@ export type Event = | GetCaCert | SignIntermediate | ImportCaCert - | GetCaCrl + | GetCaCrls | IssueCert | SignCert | GetCert diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts index 2ef924ffb..43f897a27 100644 --- a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts @@ -2,24 +2,24 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; -import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +// 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 { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; -import { TGetCrl } from "./certificate-authority-crl-types"; +import { TGetCaCrlsDTO, TGetCrlById } from "./certificate-authority-crl-types"; type TCertificateAuthorityCrlServiceFactoryDep = { certificateAuthorityDAL: Pick; - certificateAuthorityCrlDAL: Pick; + certificateAuthorityCrlDAL: Pick; projectDAL: Pick; kmsService: Pick; permissionService: Pick; - licenseService: Pick; + // licenseService: Pick; }; export type TCertificateAuthorityCrlServiceFactory = ReturnType; @@ -29,13 +29,42 @@ export const certificateAuthorityCrlServiceFactory = ({ certificateAuthorityCrlDAL, projectDAL, kmsService, - permissionService, - licenseService + permissionService // licenseService }: TCertificateAuthorityCrlServiceFactoryDep) => { /** - * Return the Certificate Revocation List (CRL) for CA with id [caId] + * Return CRL with id [crlId] */ - const getCaCrl = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCrl) => { + const getCrlById = async (crlId: TGetCrlById) => { + const caCrl = await certificateAuthorityCrlDAL.findById(crlId); + if (!caCrl) throw new NotFoundError({ message: "CRL not found" }); + + const ca = await certificateAuthorityDAL.findById(caCrl.caId); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: keyId + }); + + const decryptedCrl = await kmsDecryptor({ cipherTextBlob: caCrl.encryptedCrl }); + + const crl = new x509.X509Crl(decryptedCrl); + + return { + ca, + caCrl, + crl: crl.rawData + }; + }; + + /** + * Returns a list of CRL ids for CA with id [caId] + */ + const getCaCrls = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCrlsDTO) => { const ca = await certificateAuthorityDAL.findById(caId); if (!ca) throw new BadRequestError({ message: "CA not found" }); @@ -52,15 +81,14 @@ export const certificateAuthorityCrlServiceFactory = ({ ProjectPermissionSub.CertificateAuthorities ); - const plan = await licenseService.getPlan(actorOrgId); - if (!plan.caCrl) - throw new BadRequestError({ - message: - "Failed to get CA certificate revocation list (CRL) due to plan restriction. Upgrade plan to get the CA CRL." - }); + // const plan = await licenseService.getPlan(actorOrgId); + // if (!plan.caCrl) + // throw new BadRequestError({ + // message: + // "Failed to get CA certificate revocation lists (CRLs) due to plan restriction. Upgrade plan to get the CA CRL." + // }); - const caCrl = await certificateAuthorityCrlDAL.findOne({ caId: ca.id }); - if (!caCrl) throw new BadRequestError({ message: "CRL not found" }); + const caCrls = await certificateAuthorityCrlDAL.find({ caId: ca.id }, { sort: [["createdAt", "desc"]] }); const keyId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -72,15 +100,23 @@ export const certificateAuthorityCrlServiceFactory = ({ kmsId: keyId }); - const decryptedCrl = await kmsDecryptor({ cipherTextBlob: caCrl.encryptedCrl }); - const crl = new x509.X509Crl(decryptedCrl); + const decryptedCrls = await Promise.all( + caCrls.map(async (caCrl) => { + const decryptedCrl = await kmsDecryptor({ cipherTextBlob: caCrl.encryptedCrl }); + const crl = new x509.X509Crl(decryptedCrl); - const base64crl = crl.toString("base64"); - const crlPem = `-----BEGIN X509 CRL-----\n${base64crl.match(/.{1,64}/g)?.join("\n")}\n-----END X509 CRL-----`; + const base64crl = crl.toString("base64"); + const crlPem = `-----BEGIN X509 CRL-----\n${base64crl.match(/.{1,64}/g)?.join("\n")}\n-----END X509 CRL-----`; + return { + id: caCrl.id, + crl: crlPem + }; + }) + ); return { - crl: crlPem, - ca + ca, + crls: decryptedCrls }; }; @@ -166,7 +202,8 @@ export const certificateAuthorityCrlServiceFactory = ({ // }; return { - getCaCrl + getCrlById, + getCaCrls // rotateCaCrl }; }; diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts index fc31e9eef..9b82727e9 100644 --- a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts @@ -1,5 +1,7 @@ import { TProjectPermission } from "@app/lib/types"; -export type TGetCrl = { +export type TGetCrlById = string; + +export type TGetCaCrlsDTO = { caId: string; } & Omit; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 1aef3cc86..ea08b212a 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -98,6 +98,7 @@ export const dynamicSecretServiceFactory = ({ if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(inputs)); + const dynamicSecretCfg = await dynamicSecretDAL.create({ type: provider.type, version: 1, diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 4c0770ad9..c4a6ac3fc 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -126,7 +126,6 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); - can(OrgPermissionActions.Create, OrgPermissionSubjects.Member); can(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts index daf6d0bd8..6d5168f26 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts @@ -20,7 +20,15 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicy}.id`, `${TableName.SecretApprovalPolicyApprover}.policyId` ) - .select(tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover)) + + .leftJoin(TableName.Users, `${TableName.SecretApprovalPolicyApprover}.approverUserId`, `${TableName.Users}.id`) + + .select( + tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), + tx.ref("email").withSchema(TableName.Users).as("approverEmail"), + tx.ref("firstName").withSchema(TableName.Users).as("approverFirstName"), + tx.ref("lastName").withSchema(TableName.Users).as("approverLastName") + ) .select( tx.ref("name").withSchema(TableName.Environment).as("envName"), tx.ref("slug").withSchema(TableName.Environment).as("envSlug"), @@ -47,8 +55,11 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { { key: "approverUserId", label: "userApprovers" as const, - mapper: ({ approverUserId }) => ({ - userId: approverUserId + mapper: ({ approverUserId, approverEmail, approverFirstName, approverLastName }) => ({ + userId: approverUserId, + email: approverEmail, + firstName: approverFirstName, + lastName: approverLastName }) } ] diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts new file mode 100644 index 000000000..01b2451ac --- /dev/null +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts @@ -0,0 +1,46 @@ +import { TSecretApprovalRequests } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; + +type TSendApprovalEmails = { + secretApprovalPolicyDAL: Pick; + projectDAL: Pick; + smtpService: Pick; + projectId: string; + secretApprovalRequest: TSecretApprovalRequests; +}; + +export const sendApprovalEmailsFn = async ({ + secretApprovalPolicyDAL, + projectDAL, + smtpService, + projectId, + secretApprovalRequest +}: TSendApprovalEmails) => { + const cfg = getConfig(); + + const policy = await secretApprovalPolicyDAL.findById(secretApprovalRequest.policyId); + + const project = await projectDAL.findProjectWithOrg(projectId); + + // now we need to go through each of the reviewers and print out all the commits that they need to approve + for await (const reviewerUser of policy.userApprovers) { + await smtpService.sendMail({ + recipients: [reviewerUser?.email as string], + subjectLine: "Infisical Secret Change Request", + + substitutions: { + firstName: reviewerUser.firstName, + projectName: project.name, + organizationName: project.organization.name, + approvalUrl: `${cfg.isDevelopmentMode ? "https" : "http"}://${cfg.SITE_URL}/project/${ + project.id + }/approval?requestId=${secretApprovalRequest.id}` + }, + template: SmtpTemplates.SecretApprovalRequestNeedsReview + }); + } +}; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index f6bb33168..80913c3d9 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -53,8 +53,10 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service"; import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal"; +import { sendApprovalEmailsFn } from "./secret-approval-request-fns"; import { TSecretApprovalRequestReviewerDALFactory } from "./secret-approval-request-reviewer-dal"; import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal"; import { @@ -89,7 +91,10 @@ type TSecretApprovalRequestServiceFactoryDep = { smtpService: Pick; userDAL: Pick; projectEnvDAL: Pick; - projectDAL: Pick; + projectDAL: Pick< + TProjectDALFactory, + "checkProjectUpgradeStatus" | "findById" | "findProjectById" | "findProjectWithOrg" + >; secretQueueService: Pick; kmsService: Pick; secretV2BridgeDAL: Pick< @@ -98,6 +103,7 @@ type TSecretApprovalRequestServiceFactoryDep = { >; secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; + secretApprovalPolicyDAL: Pick; licenseService: Pick; }; @@ -121,6 +127,7 @@ export const secretApprovalRequestServiceFactory = ({ smtpService, userDAL, projectEnvDAL, + secretApprovalPolicyDAL, kmsService, secretV2BridgeDAL, secretVersionV2BridgeDAL, @@ -1061,6 +1068,15 @@ export const secretApprovalRequestServiceFactory = ({ } return { ...doc, commits: approvalCommits }; }); + + await sendApprovalEmailsFn({ + projectDAL, + secretApprovalPolicyDAL, + secretApprovalRequest, + smtpService, + projectId + }); + return secretApprovalRequest; }; @@ -1311,8 +1327,17 @@ export const secretApprovalRequestServiceFactory = ({ tx ); } + return { ...doc, commits: approvalCommits }; }); + + await sendApprovalEmailsFn({ + projectDAL, + secretApprovalPolicyDAL, + secretApprovalRequest, + smtpService, + projectId + }); return secretApprovalRequest; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index f4d645165..2a823024a 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1120,9 +1120,10 @@ export const CERTIFICATE_AUTHORITIES = { certificateChain: "The certificate chain of the issued certificate", serialNumber: "The serial number of the issued certificate" }, - GET_CRL: { - caId: "The ID of the CA to get the certificate revocation list (CRL) for", - crl: "The certificate revocation list (CRL) of the CA" + GET_CRLS: { + caId: "The ID of the CA to get the certificate revocation lists (CRLs) for", + id: "The ID of certificate revocation list (CRL)", + crl: "The certificate revocation list (CRL)" } }; @@ -1174,6 +1175,13 @@ export const CERTIFICATE_TEMPLATES = { } }; +export const CA_CRLS = { + GET: { + crlId: "The ID of the certificate revocation list (CRL) to get", + crl: "The certificate revocation list (CRL)" + } +}; + export const ALERTS = { CREATE: { projectId: "The ID of the project to create the alert in", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index cd64c2912..ca19fd425 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -74,6 +74,7 @@ const envSchema = z JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")), JWT_SIGNUP_LIFETIME: zpStr(z.string().default("15m")), JWT_REFRESH_LIFETIME: zpStr(z.string().default("90d")), + JWT_INVITE_LIFETIME: zpStr(z.string().default("1d")), JWT_MFA_LIFETIME: zpStr(z.string().default("5m")), JWT_PROVIDER_AUTH_LIFETIME: zpStr(z.string().default("15m")), // Oauth diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 79ec8b84d..5d20f1cff 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -480,9 +480,12 @@ export const registerRoutes = async ( orgRoleDAL, permissionService, orgDAL, + userGroupMembershipDAL, + projectBotDAL, incidentContactDAL, tokenService, projectUserAdditionalPrivilegeDAL, + projectUserMembershipRoleDAL, projectDAL, projectMembershipDAL, orgMembershipDAL, @@ -502,6 +505,8 @@ export const registerRoutes = async ( projectDAL, projectBotDAL, groupProjectDAL, + projectMembershipDAL, + projectUserMembershipRoleDAL, orgDAL, orgService, licenseService @@ -650,8 +655,8 @@ export const registerRoutes = async ( certificateAuthorityCrlDAL, projectDAL, kmsService, - permissionService, - licenseService + permissionService + // licenseService }); const certificateTemplateService = certificateTemplateServiceFactory({ @@ -700,6 +705,7 @@ export const registerRoutes = async ( orgDAL, orgService, projectMembershipDAL, + projectRoleDAL, folderDAL, licenseService, certificateAuthorityDAL, @@ -856,6 +862,7 @@ export const registerRoutes = async ( secretQueueService, kmsService, secretV2BridgeDAL, + secretApprovalPolicyDAL, secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, smtpService, diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 1fd9cabbb..9a866f66e 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -699,4 +699,83 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "GET", + url: "/:caId/crls", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get list of CRLs of the CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CRLS.caId) + }), + response: { + 200: z.array( + z.object({ + id: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CRLS.id), + crl: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CRLS.crl) + }) + ) + } + }, + handler: async (req) => { + const { ca, crls } = await server.services.certificateAuthorityCrl.getCaCrls({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CRLS, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return crls; + } + }); + + // TODO: implement this endpoint in the future + // server.route({ + // method: "GET", + // url: "/:caId/crl/rotate", + // config: { + // rateLimit: writeLimit + // }, + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + // schema: { + // description: "Rotate CRLs of the CA", + // params: z.object({ + // caId: z.string().trim() + // }), + // response: { + // 200: z.object({ + // message: z.string() + // }) + // } + // }, + // handler: async (req) => { + // await server.services.certificateAuthority.rotateCaCrl({ + // caId: req.params.caId, + // actor: req.permission.type, + // actorId: req.permission.id, + // actorAuthMethod: req.permission.authMethod, + // actorOrgId: req.permission.orgId + // }); + // return { + // message: "Successfully rotated CA CRL" + // }; + // } + // }); }; diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 963b7101c..4baa39f76 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -293,6 +293,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }), querystring: z.object({ teamId: z.string().trim().optional(), + azureDevOpsOrgName: z.string().trim().optional(), workspaceSlug: z.string().trim().optional() }), response: { diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index 873710f10..c2907d3ca 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { UsersSchema } from "@app/db/schemas"; +import { OrgMembershipRole, ProjectMembershipRole, UsersSchema } from "@app/db/schemas"; import { inviteUserRateLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -16,23 +16,37 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { method: "POST", schema: { body: z.object({ - inviteeEmail: z.string().trim().email(), - organizationId: z.string().trim() + inviteeEmails: z.array(z.string().trim().email()), + organizationId: z.string().trim(), + projectIds: z.array(z.string().trim()).optional(), + projectRoleSlug: z.nativeEnum(ProjectMembershipRole).optional(), + organizationRoleSlug: z.nativeEnum(OrgMembershipRole) }), response: { 200: z.object({ message: z.string(), - completeInviteLink: z.string().optional() + completeInviteLinks: z + .array( + z.object({ + email: z.string(), + link: z.string() + }) + ) + .optional() }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; - const completeInviteLink = await server.services.org.inviteUserToOrganization({ + + const completeInviteLinks = await server.services.org.inviteUserToOrganization({ orgId: req.body.organizationId, userId: req.permission.id, - inviteeEmail: req.body.inviteeEmail, + inviteeEmails: req.body.inviteeEmails, + projectIds: req.body.projectIds, + projectRoleSlug: req.body.projectRoleSlug, + organizationRoleSlug: req.body.organizationRoleSlug, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId }); @@ -41,14 +55,15 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { event: PostHogEventTypes.UserOrgInvitation, distinctId: getTelemetryDistinctId(req), properties: { - inviteeEmail: req.body.inviteeEmail, + inviteeEmails: req.body.inviteeEmails, + organizationRoleSlug: req.body.organizationRoleSlug, ...req.auditLogInfo } }); return { - completeInviteLink, - message: `Send an invite link to ${req.body.inviteeEmail}` + completeInviteLinks, + message: `Send an invite link to ${req.body.inviteeEmails.join(", ")}` }; } }); diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index ac9703f07..a380eb934 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -1,6 +1,12 @@ import { z } from "zod"; -import { IntegrationsSchema, ProjectMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { + IntegrationsSchema, + ProjectMembershipsSchema, + ProjectRolesSchema, + UserEncryptionKeysSchema, + UsersSchema +} from "@app/db/schemas"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -122,15 +128,31 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + querystring: z.object({ + includeRoles: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }), response: { 200: z.object({ - workspaces: projectWithEnv.array() + workspaces: projectWithEnv + .extend({ + roles: ProjectRolesSchema.array().optional() + }) + .array() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), handler: async (req) => { - const workspaces = await server.services.project.getProjects(req.permission.id); + const workspaces = await server.services.project.getProjects({ + includeRoles: req.query.includeRoles, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId + }); return { workspaces }; } }); diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index 59131464a..2b603f523 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -179,7 +179,8 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { encryptedPrivateKeyIV: z.string().trim(), encryptedPrivateKeyTag: z.string().trim(), salt: z.string().trim(), - verifier: z.string().trim() + verifier: z.string().trim(), + tokenMetadata: z.string().optional() }), response: { 200: z.object({ diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 8917bd672..65d16850a 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -1,3 +1,5 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; + export enum TokenType { TOKEN_EMAIL_CONFIRMATION = "emailConfirmation", TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified @@ -49,3 +51,19 @@ export type TIssueAuthTokenDTO = { ip: string; userAgent: string; }; + +export enum TokenMetadataType { + InviteToProjects = "projects-invite" +} + +export type TTokenInviteToProjectsMetadataPayload = { + projectIds: string[]; + projectRoleSlug: ProjectMembershipRole; + userId: string; + orgId: string; +}; + +export type TTokenMetadata = { + type: TokenMetadataType.InviteToProjects; + payload: TTokenInviteToProjectsMetadataPayload; +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 2ce900b47..91bf40198 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -583,7 +583,13 @@ export const authLoginServiceFactory = ({ } else { const isLinkingRequired = !user?.authMethods?.includes(authMethod); if (isLinkingRequired) { - user = await userDAL.updateById(user.id, { authMethods: [...(user.authMethods || []), authMethod] }); + // we update the names here because upon org invitation, the names are set to be NULL + // if user is signing up with SSO after invitation, their names should be set based on their SSO profile + user = await userDAL.updateById(user.id, { + authMethods: [...(user.authMethods || []), authMethod], + firstName: !user.isAccepted ? firstName : undefined, + lastName: !user.isAccepted ? lastName : undefined + }); } } diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 83a5b27d9..7cca07110 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -9,7 +9,7 @@ import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { isDisposableEmail } from "@app/lib/validator"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -17,9 +17,12 @@ import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; -import { TokenType } from "../auth-token/auth-token-types"; +import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types"; import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; +import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { addMembersToProject } from "../project-membership/project-membership-fns"; +import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TAuthDALFactory } from "./auth-dal"; @@ -32,10 +35,14 @@ type TAuthSignupDep = { userDAL: TUserDALFactory; userGroupMembershipDAL: Pick< TUserGroupMembershipDALFactory, - "find" | "transaction" | "insertMany" | "deletePendingUserGroupMembershipsByUserIds" + | "find" + | "transaction" + | "insertMany" + | "deletePendingUserGroupMembershipsByUserIds" + | "findUserGroupMembershipsInProject" >; projectKeyDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; projectBotDAL: Pick; groupProjectDAL: Pick; orgService: Pick; @@ -43,6 +50,8 @@ type TAuthSignupDep = { tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; licenseService: Pick; + projectMembershipDAL: Pick; + projectUserMembershipRoleDAL: Pick; }; export type TAuthSignupFactory = ReturnType; @@ -58,6 +67,8 @@ export const authSignupServiceFactory = ({ smtpService, orgService, orgDAL, + projectMembershipDAL, + projectUserMembershipRoleDAL, licenseService }: TAuthSignupDep) => { // first step of signup. create user and send email @@ -301,7 +312,8 @@ export const authSignupServiceFactory = ({ encryptedPrivateKey, encryptedPrivateKeyIV, encryptedPrivateKeyTag, - authorization + authorization, + tokenMetadata }: TCompleteAccountInviteDTO) => { const user = await userDAL.findUserByUsername(email); if (!user || (user && user.isAccepted)) { @@ -358,6 +370,45 @@ export const authSignupServiceFactory = ({ tx ); + if (tokenMetadata) { + const metadataObj = jwt.verify(tokenMetadata, appCfg.AUTH_SECRET) as TTokenMetadata; + + if ( + metadataObj?.payload?.userId !== user.id || + metadataObj?.payload?.orgId !== orgMembership.orgId || + metadataObj?.type !== TokenMetadataType.InviteToProjects + ) { + throw new UnauthorizedError({ + message: "Malformed or invalid metadata token" + }); + } + + for await (const projectId of metadataObj.payload.projectIds) { + await addMembersToProject({ + orgDAL, + projectDAL, + projectMembershipDAL, + projectKeyDAL, + userGroupMembershipDAL, + projectBotDAL, + projectUserMembershipRoleDAL, + smtpService + }).addMembersToNonE2EEProject( + { + emails: [user.email!], + usernames: [], + projectId, + projectMembershipRole: metadataObj.payload.projectRoleSlug, + sendEmails: false + }, + { + tx, + throwOnProjectNotFound: false + } + ); + } + } + const updatedMembersips = await orgDAL.updateMembership( { inviteEmail: email, status: OrgMembershipStatus.Invited }, { userId: us.id, status: OrgMembershipStatus.Accepted }, diff --git a/backend/src/services/auth/auth-signup-type.ts b/backend/src/services/auth/auth-signup-type.ts index 9cd70f8c7..3308b9d12 100644 --- a/backend/src/services/auth/auth-signup-type.ts +++ b/backend/src/services/auth/auth-signup-type.ts @@ -37,4 +37,5 @@ export type TCompleteAccountInviteDTO = { ip: string; userAgent: string; authorization: string; + tokenMetadata?: string; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index b1fd87a26..7330f029b 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -13,6 +13,13 @@ import { TRebuildCaCrlDTO } from "./certificate-authority-types"; +/* eslint-disable no-bitwise */ +export const createSerialNumber = () => { + const randomBytes = crypto.randomBytes(32); + randomBytes[0] &= 0x7f; // ensure the first bit is 0 + return randomBytes.toString("hex"); +}; + export const createDistinguishedName = (parts: TDNParts) => { const dnParts = []; if (parts.country) dnParts.push(`C=${parts.country}`); @@ -284,12 +291,11 @@ export const rebuildCaCrl = async ({ thisUpdate: new Date(), nextUpdate: new Date("2025/12/12"), entries: revokedCerts.map((revokedCert) => { + const revocationDate = new Date(revokedCert.revokedAt as Date); return { serialNumber: revokedCert.serialNumber, - revocationDate: new Date(revokedCert.revokedAt as Date), - reason: revokedCert.revocationReason as number, - invalidity: new Date("2022/01/01"), - issuer: ca.dn + revocationDate, + reason: revokedCert.revocationReason as number }; }), signingAlgorithm: alg, diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index dd7815820..a1f062a45 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; @@ -25,6 +26,7 @@ import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cer import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { createDistinguishedName, + createSerialNumber, getCaCertChain, // TODO: consider rename getCaCertChains, getCaCredentials, @@ -147,7 +149,7 @@ export const certificateAuthorityServiceFactory = ({ ? new Date(notAfter) : new Date(new Date().setFullYear(new Date().getFullYear() + 10)); - const serialNumber = crypto.randomBytes(32).toString("hex"); + const serialNumber = createSerialNumber(); const ca = await certificateAuthorityDAL.create( { @@ -263,7 +265,8 @@ export const certificateAuthorityServiceFactory = ({ await certificateAuthorityCrlDAL.create( { caId: ca.id, - encryptedCrl + encryptedCrl, + caSecretId: caSecret.id }, tx ); @@ -433,7 +436,7 @@ export const certificateAuthorityServiceFactory = ({ // get latest CA certificate const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); - const serialNumber = crypto.randomBytes(32).toString("hex"); + const serialNumber = createSerialNumber(); const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -846,7 +849,7 @@ export const certificateAuthorityServiceFactory = ({ kmsService }); - const serialNumber = crypto.randomBytes(32).toString("hex"); + const serialNumber = createSerialNumber(); const intermediateCert = await x509.X509CertificateGenerator.create({ serialNumber, subject: csrObj.subject, @@ -1142,7 +1145,7 @@ export const certificateAuthorityServiceFactory = ({ attributes: [new x509.ChallengePasswordAttribute("password")] }); - const { caPrivateKey } = await getCaCredentials({ + const { caPrivateKey, caSecret } = await getCaCredentials({ caId: ca.id, certificateAuthorityDAL, certificateAuthoritySecretDAL, @@ -1150,9 +1153,15 @@ export const certificateAuthorityServiceFactory = ({ kmsService }); + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const appCfg = getConfig(); + + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}`; + const extensions: x509.Extension[] = [ new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true), new x509.BasicConstraintsExtension(false), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) ]; @@ -1203,7 +1212,7 @@ export const certificateAuthorityServiceFactory = ({ ); } - const serialNumber = crypto.randomBytes(32).toString("hex"); + const serialNumber = createSerialNumber(); const leafCert = await x509.X509CertificateGenerator.create({ serialNumber, subject: csrObj.subject, @@ -1486,7 +1495,7 @@ export const certificateAuthorityServiceFactory = ({ ); } - const serialNumber = crypto.randomBytes(32).toString("hex"); + const serialNumber = createSerialNumber(); const leafCert = await x509.X509CertificateGenerator.create({ serialNumber, subject: csrObj.subject, diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 9cb0d822c..bd7c19228 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -1030,11 +1030,31 @@ const getAppsCloud66 = async ({ accessToken }: { accessToken: string }) => { return apps; }; +const getAppsAzureDevOps = async ({ accessToken, orgName }: { accessToken: string; orgName: string }) => { + const res = ( + await request.get<{ count: number; value: Record[] }>( + `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${orgName}/_apis/projects?api-version=7.2-preview.2`, + { + headers: { + Authorization: `Basic ${accessToken}` + } + } + ) + ).data; + const apps = res.value.map((a) => ({ + name: a.name, + appId: a.id + })); + + return apps; +}; + export const getApps = async ({ integration, accessToken, accessId, teamId, + azureDevOpsOrgName, workspaceSlug, url }: { @@ -1042,6 +1062,7 @@ export const getApps = async ({ accessToken: string; accessId?: string; teamId?: string | null; + azureDevOpsOrgName?: string | null; workspaceSlug?: string; url?: string | null; }): Promise => { @@ -1184,6 +1205,12 @@ export const getApps = async ({ accessToken }); + case Integrations.AZURE_DEVOPS: + return getAppsAzureDevOps({ + accessToken, + orgName: azureDevOpsOrgName as string + }); + default: throw new BadRequestError({ message: "integration not found" }); } diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index e16f6bb77..7b201e6ea 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -440,6 +440,7 @@ export const integrationAuthServiceFactory = ({ actorOrgId, actorAuthMethod, teamId, + azureDevOpsOrgName, id, workspaceSlug }: TIntegrationAuthAppsDTO) => { @@ -462,6 +463,7 @@ export const integrationAuthServiceFactory = ({ accessToken, accessId, teamId, + azureDevOpsOrgName, workspaceSlug, url: integrationAuth.url }); diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 5d1bfc18f..af390297a 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -1,3 +1,4 @@ +import { TIntegrations } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export type TGetIntegrationAuthDTO = { @@ -28,6 +29,7 @@ export type TDeleteIntegrationAuthsDTO = TProjectPermission & { export type TIntegrationAuthAppsDTO = { id: string; teamId?: string; + azureDevOpsOrgName?: string; workspaceSlug?: string; } & Omit; @@ -163,3 +165,13 @@ export type TTeamCityBuildConfig = { href: string; webUrl: string; }; + +export type TIntegrationsWithEnvironment = TIntegrations & { + environment?: + | { + id?: string | null | undefined; + name?: string | null | undefined; + } + | null + | undefined; +}; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index edc426327..b91654474 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -31,7 +31,8 @@ export enum Integrations { CLOUD_66 = "cloud-66", NORTHFLANK = "northflank", HASURA_CLOUD = "hasura-cloud", - RUNDECK = "rundeck" + RUNDECK = "rundeck", + AZURE_DEVOPS = "azure-devops" } export enum IntegrationType { @@ -88,6 +89,7 @@ export enum IntegrationUrls { CLOUD_66_API_URL = "https://app.cloud66.com/api", NORTHFLANK_API_URL = "https://api.northflank.com", HASURA_CLOUD_API_URL = "https://data.pro.hasura.io/v1/graphql", + AZURE_DEVOPS_API_URL = "https://dev.azure.com", GCP_SECRET_MANAGER_SERVICE_NAME = "secretmanager.googleapis.com", GCP_SECRET_MANAGER_URL = `https://${GCP_SECRET_MANAGER_SERVICE_NAME}`, @@ -378,6 +380,15 @@ export const getIntegrationOptions = async () => { type: "pat", clientId: "", docsLink: "" + }, + { + name: "Azure DevOps", + slug: "azure-devops", + image: "Microsoft Azure.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" } ]; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index b1f7d4cb8..91d9a68b5 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -35,6 +35,7 @@ import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/ import { TIntegrationDALFactory } from "../integration/integration-dal"; import { IntegrationMetadataSchema } from "../integration/integration-schema"; +import { TIntegrationsWithEnvironment } from "./integration-auth-types"; import { IntegrationInitialSyncBehavior, IntegrationMappingBehavior, @@ -2075,6 +2076,116 @@ const syncSecretsTravisCI = async ({ } }; +/** + * Sync/push [secrets] to GitLab repo with name [integration.app] + */ +const syncSecretsAzureDevops = async ({ + integrationAuth, + integration, + secrets, + accessToken +}: { + integrationAuth: TIntegrationAuths; + integration: TIntegrationsWithEnvironment; + secrets: Record; + accessToken: string; +}) => { + if (!integration.appId || !integration.app) { + throw new Error("Azure DevOps: orgId and projectId are required"); + } + if (!integration.environment || !integration.environment.name) { + throw new Error("Azure DevOps: environment is required"); + } + const headers = { + Authorization: `Basic ${accessToken}` + }; + const azureDevopsApiUrl = integrationAuth.url ? `${integrationAuth.url}` : IntegrationUrls.AZURE_DEVOPS_API_URL; + + const getEnvGroupId = async (orgId: string, project: string, env: string) => { + let groupId; + const url: string | null = + `${azureDevopsApiUrl}/${orgId}/${project}/_apis/distributedtask/variablegroups?api-version=7.2-preview.2`; + + const response = await request.get(url, { headers }); + for (const group of response.data.value) { + const groupName = group.name; + if (groupName === env) { + groupId = group.id; + return { groupId, groupName }; + } + } + return { groupId: "", groupName: "" }; + }; + + const { groupId, groupName } = await getEnvGroupId(integration.app, integration.appId, integration.environment.name); + + const variables: Record = {}; + for (const key of Object.keys(secrets)) { + variables[key] = { value: secrets[key].value }; + } + + if (!groupId) { + // create new variable group if not present + const url = `${azureDevopsApiUrl}/${integration.app}/_apis/distributedtask/variablegroups?api-version=7.2-preview.2`; + const config = { + method: "POST", + url, + data: { + name: integration.environment.name, + description: integration.environment.name, + type: "Vsts", + owner: "Library", + variables, + variableGroupProjectReferences: [ + { + name: integration.environment.name, + projectReference: { + name: integration.appId + } + } + ] + }, + headers: { + headers + } + }; + + const res = await request.post(url, config.data, config.headers); + if (res.status !== 200) { + throw new Error(`Azure DevOps: Failed to create variable group: ${res.statusText}`); + } + } else { + // sync variables for pre-existing variable group + const url = `${azureDevopsApiUrl}/${integration.app}/_apis/distributedtask/variablegroups/${groupId}?api-version=7.2-preview.2`; + const config = { + method: "PUT", + url, + data: { + name: groupName, + description: groupName, + type: "Vsts", + owner: "Library", + variables, + variableGroupProjectReferences: [ + { + name: groupName, + projectReference: { + name: integration.appId + } + } + ] + }, + headers: { + headers + } + }; + const res = await request.put(url, config.data, config.headers); + if (res.status !== 200) { + throw new Error(`Azure DevOps: Failed to update variable group: ${res.statusText}`); + } + } +}; + /** * Sync/push [secrets] to GitLab repo with name [integration.app] */ @@ -3714,6 +3825,15 @@ export const syncIntegrationSecrets = async ({ updateManySecretsRawFn }); break; + + case Integrations.AZURE_DEVOPS: + await syncSecretsAzureDevops({ + integrationAuth, + integration, + secrets, + accessToken + }); + break; case Integrations.AWS_PARAMETER_STORE: response = await syncSecretsAWSParameterStore({ integration, diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 051869429..d7c6ba31a 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -114,10 +114,11 @@ export const orgDALFactory = (db: TDbClient) => { } }; - const findOrgMembersByUsername = async (orgId: string, usernames: string[]) => { + const findOrgMembersByUsername = async (orgId: string, usernames: string[], tx?: Knex) => { try { - const members = await db - .replicaNode()(TableName.OrgMembership) + const conn = tx || db; + const members = await conn(TableName.OrgMembership) + // .replicaNode()(TableName.OrgMembership) .where(`${TableName.OrgMembership}.orgId`, orgId) .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .leftJoin( @@ -126,18 +127,18 @@ export const orgDALFactory = (db: TDbClient) => { `${TableName.Users}.id` ) .select( - db.ref("id").withSchema(TableName.OrgMembership), - db.ref("inviteEmail").withSchema(TableName.OrgMembership), - db.ref("orgId").withSchema(TableName.OrgMembership), - db.ref("role").withSchema(TableName.OrgMembership), - db.ref("roleId").withSchema(TableName.OrgMembership), - db.ref("status").withSchema(TableName.OrgMembership), - db.ref("username").withSchema(TableName.Users), - db.ref("email").withSchema(TableName.Users), - db.ref("firstName").withSchema(TableName.Users), - db.ref("lastName").withSchema(TableName.Users), - db.ref("id").withSchema(TableName.Users).as("userId"), - db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + conn.ref("id").withSchema(TableName.OrgMembership), + conn.ref("inviteEmail").withSchema(TableName.OrgMembership), + conn.ref("orgId").withSchema(TableName.OrgMembership), + conn.ref("role").withSchema(TableName.OrgMembership), + conn.ref("roleId").withSchema(TableName.OrgMembership), + conn.ref("status").withSchema(TableName.OrgMembership), + conn.ref("username").withSchema(TableName.Users), + conn.ref("email").withSchema(TableName.Users), + conn.ref("firstName").withSchema(TableName.Users), + conn.ref("lastName").withSchema(TableName.Users), + conn.ref("id").withSchema(TableName.Users).as("userId"), + conn.ref("publicKey").withSchema(TableName.UserEncryptionKey) ) .where({ isGhost: false }) .whereIn("username", usernames); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 23e0aeeff..6696a5323 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -4,9 +4,17 @@ import crypto from "crypto"; import jwt from "jsonwebtoken"; import { Knex } from "knex"; -import { OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { + OrgMembershipRole, + OrgMembershipStatus, + ProjectMembershipRole, + ProjectVersion, + TableName, + TUsers +} from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-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"; @@ -24,10 +32,14 @@ import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; -import { TokenType } from "../auth-token/auth-token-types"; +import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types"; import { TProjectDALFactory } from "../project/project-dal"; +import { verifyProjectVersions } from "../project/project-fns"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { addMembersToProject } from "../project-membership/project-membership-fns"; +import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; @@ -56,8 +68,11 @@ type TOrgServiceFactoryDep = { userDAL: TUserDALFactory; groupDAL: TGroupDALFactory; projectDAL: TProjectDALFactory; - projectMembershipDAL: Pick; - projectKeyDAL: Pick; + projectMembershipDAL: Pick< + TProjectMembershipDALFactory, + "findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction" + >; + projectKeyDAL: Pick; orgMembershipDAL: Pick; incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; @@ -69,6 +84,9 @@ type TOrgServiceFactoryDep = { "getPlan" | "updateSubscriptionOrgMemberCount" | "generateOrgCustomerId" | "removeOrgCustomer" >; projectUserAdditionalPrivilegeDAL: Pick; + userGroupMembershipDAL: Pick; + projectBotDAL: Pick; + projectUserMembershipRoleDAL: Pick; }; export type TOrgServiceFactory = ReturnType; @@ -90,7 +108,10 @@ export const orgServiceFactory = ({ tokenService, orgBotDAL, licenseService, - samlConfigDAL + samlConfigDAL, + userGroupMembershipDAL, + projectBotDAL, + projectUserMembershipRoleDAL }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -420,10 +441,15 @@ export const orgServiceFactory = ({ const inviteUserToOrganization = async ({ orgId, userId, - inviteeEmail, + inviteeEmails, + organizationRoleSlug, + projectRoleSlug, + projectIds, actorAuthMethod, actorOrgId }: TInviteUserToOrgDTO) => { + const appCfg = getConfig(); + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); @@ -450,98 +476,203 @@ export const orgServiceFactory = ({ }); } - const invitee = await orgDAL.transaction(async (tx) => { - const inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); - if (inviteeUser) { - // if user already exist means its already part of infisical - // Thus the signup flow is not needed anymore - const [inviteeMembership] = await orgDAL.findMembership( - { - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, - [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUser.id - }, - { tx } - ); - if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) { - throw new BadRequestError({ - message: "Failed to invite an existing member of org", - name: "Invite user to org" - }); + if (projectIds?.length) { + const projects = await projectDAL.find({ + orgId, + $in: { + id: projectIds } + }); - if (!inviteeMembership) { - await orgDAL.createMembership( - { - userId: inviteeUser.id, - inviteEmail: inviteeEmail, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited, - isActive: true - }, - tx - ); - } - return inviteeUser; - } - const isEmailInvalid = await isDisposableEmail(inviteeEmail); - if (isEmailInvalid) { + // if its not v3, throw an error + if (!verifyProjectVersions(projects, ProjectVersion.V3)) { throw new BadRequestError({ - message: "Provided a disposable email", - name: "Org invite" + message: "One or more selected projects are not compatible with this operation. Please upgrade your projects." }); } - // not invited before - const user = await userDAL.create( - { - username: inviteeEmail, - email: inviteeEmail, - isAccepted: false, - authMethods: [AuthMethod.EMAIL], - isGhost: false - }, - tx - ); - await orgDAL.createMembership( - { - inviteEmail: inviteeEmail, - orgId, - userId: user.id, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited, - isActive: true - }, - tx - ); - return user; - }); + } - const token = await tokenService.createTokenForUser({ - type: TokenType.TOKEN_EMAIL_ORG_INVITATION, - userId: invitee.id, - orgId + const inviteeUsers = await orgDAL.transaction(async (tx) => { + const users: Pick< + TUsers & { orgId: string }, + "id" | "firstName" | "lastName" | "email" | "orgId" | "username" + >[] = []; + for await (const inviteeEmail of inviteeEmails) { + const inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); + + if (inviteeUser) { + // if user already exist means its already part of infisical + // Thus the signup flow is not needed anymore + const [inviteeMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUser.id + }, + { tx } + ); + if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) { + throw new BadRequestError({ + message: `Failed to invite members because ${inviteeEmail} is already part of the organization`, + name: "Invite user to org" + }); + } + + if (!inviteeMembership) { + await orgDAL.createMembership( + { + userId: inviteeUser.id, + inviteEmail: inviteeEmail, + orgId, + role: OrgMembershipRole.Member, + status: OrgMembershipStatus.Invited, + isActive: true + }, + tx + ); + + if (projectIds?.length) { + if ( + organizationRoleSlug === OrgMembershipRole.Custom || + projectRoleSlug === ProjectMembershipRole.Custom + ) { + throw new BadRequestError({ + message: "Custom roles are not supported for inviting users to projects and organizations" + }); + } + + if (!projectRoleSlug) { + throw new BadRequestError({ + message: "Selecting a project role is required to invite users to projects" + }); + } + + await projectMembershipDAL.insertMany( + projectIds.map((id) => ({ projectId: id, userId: inviteeUser.id })), + tx + ); + for await (const projectId of projectIds) { + await addMembersToProject({ + orgDAL, + projectDAL, + projectMembershipDAL, + projectKeyDAL, + userGroupMembershipDAL, + projectBotDAL, + projectUserMembershipRoleDAL, + smtpService + }).addMembersToNonE2EEProject( + { + emails: [inviteeEmail], + usernames: [], + projectId, + projectMembershipRole: projectRoleSlug, + sendEmails: false + }, + { + tx + } + ); + } + } + } + return [{ ...inviteeUser, orgId }]; + } + const isEmailInvalid = await isDisposableEmail(inviteeEmail); + if (isEmailInvalid) { + throw new BadRequestError({ + message: "Provided a disposable email", + name: "Org invite" + }); + } + // not invited before + const user = await userDAL.create( + { + username: inviteeEmail, + email: inviteeEmail, + isAccepted: false, + authMethods: [AuthMethod.EMAIL], + isGhost: false + }, + tx + ); + await orgDAL.createMembership( + { + inviteEmail: inviteeEmail, + orgId, + userId: user.id, + role: organizationRoleSlug, + status: OrgMembershipStatus.Invited, + isActive: true + }, + tx + ); + + users.push({ + ...user, + orgId + }); + } + return users; }); const user = await userDAL.findById(userId); - const appCfg = getConfig(); - await smtpService.sendMail({ - template: SmtpTemplates.OrgInvite, - subjectLine: "Infisical organization invitation", - recipients: [inviteeEmail], - substitutions: { - inviterFirstName: user.firstName, - inviterUsername: user.username, - organizationName: org?.name, - email: inviteeEmail, - organizationId: org?.id.toString(), - token, - callback_url: `${appCfg.SITE_URL}/signupinvite` - } - }); + const signupTokens: { email: string; link: string }[] = []; + if (inviteeUsers) { + for await (const invitee of inviteeUsers) { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: invitee.id, + orgId + }); + + let inviteMetadata: string = ""; + if (projectIds && projectIds?.length > 0) { + inviteMetadata = jwt.sign( + { + type: TokenMetadataType.InviteToProjects, + payload: { + projectIds, + projectRoleSlug: projectRoleSlug!, // Implicitly checked inside transaction if projectRoleSlug is undefined + userId: invitee.id, + orgId + } + } satisfies TTokenMetadata, + appCfg.AUTH_SECRET, + { + expiresIn: appCfg.JWT_INVITE_LIFETIME + } + ); + } + + signupTokens.push({ + email: invitee.email || invitee.username, + link: `${appCfg.SITE_URL}/signupinvite?token=${token}${ + inviteMetadata ? `&metadata=${inviteMetadata}` : "" + }&to=${invitee.email || invitee.username}&organization_id=${org?.id}` + }); + + await smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: "Infisical organization invitation", + recipients: [invitee.email || invitee.username], + substitutions: { + metadata: inviteMetadata, + inviterFirstName: user.firstName, + inviterUsername: user.username, + organizationName: org?.name, + email: invitee.email || invitee.username, + organizationId: org?.id.toString(), + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } + }); + } + } await licenseService.updateSubscriptionOrgMemberCount(orgId); + if (!appCfg.isSmtpConfigured) { - return `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${inviteeEmail}&organization_id=${org?.id}`; + return signupTokens; } }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 08b4c1c92..3fa4ae493 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -1,3 +1,4 @@ +import { OrgMembershipRole, ProjectMembershipRole } from "@app/db/schemas"; import { TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -29,7 +30,10 @@ export type TInviteUserToOrgDTO = { orgId: string; actorOrgId: string | undefined; actorAuthMethod: ActorAuthMethod; - inviteeEmail: string; + inviteeEmails: string[]; + organizationRoleSlug: OrgMembershipRole; + projectIds?: string[]; + projectRoleSlug?: ProjectMembershipRole; }; export type TVerifyUserToOrgDTO = { diff --git a/backend/src/services/project-membership/project-membership-fns.ts b/backend/src/services/project-membership/project-membership-fns.ts new file mode 100644 index 000000000..98acd6eec --- /dev/null +++ b/backend/src/services/project-membership/project-membership-fns.ts @@ -0,0 +1,190 @@ +import { Knex } from "knex"; + +import { ProjectMembershipRole, SecretKeyEncoding, TProjectMemberships } from "@app/db/schemas"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; +import { getConfig } from "@app/lib/config/env"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; + +import { TOrgDALFactory } from "../org/org-dal"; +import { TProjectDALFactory } from "../project/project-dal"; +import { assignWorkspaceKeysToMembers } from "../project/project-fns"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { TProjectMembershipDALFactory } from "./project-membership-dal"; +import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; + +type TAddMembersToProjectArg = { + orgDAL: Pick; + projectMembershipDAL: Pick; + projectDAL: Pick; + projectKeyDAL: Pick; + projectBotDAL: Pick; + userGroupMembershipDAL: Pick; + projectUserMembershipRoleDAL: Pick; + smtpService: Pick; +}; + +type AddMembersToNonE2EEProjectDTO = { + emails: string[]; + usernames: string[]; + projectId: string; + projectMembershipRole: ProjectMembershipRole; + sendEmails?: boolean; +}; + +type AddMembersToNonE2EEProjectOptions = { + tx?: Knex; + throwOnProjectNotFound?: boolean; +}; + +export const addMembersToProject = ({ + orgDAL, + projectDAL, + projectMembershipDAL, + projectKeyDAL, + projectBotDAL, + userGroupMembershipDAL, + projectUserMembershipRoleDAL, + smtpService +}: TAddMembersToProjectArg) => { + // Can create multiple memberships for a singular project, based on user email / username + const addMembersToNonE2EEProject = async ( + { emails, usernames, projectId, projectMembershipRole, sendEmails }: AddMembersToNonE2EEProjectDTO, + options: AddMembersToNonE2EEProjectOptions = { throwOnProjectNotFound: true } + ) => { + const processTransaction = async (tx: Knex) => { + const usernamesAndEmails = [...emails, ...usernames]; + + const project = await projectDAL.findProjectById(projectId); + if (!project) { + if (options.throwOnProjectNotFound) { + throw new BadRequestError({ message: "Project not found when attempting to add user to project" }); + } + + return []; + } + + const orgMembers = await orgDAL.findOrgMembersByUsername( + project.orgId, + [...new Set(usernamesAndEmails.map((element) => element.toLowerCase()))], + tx + ); + + if (orgMembers.length !== usernamesAndEmails.length) + throw new BadRequestError({ message: "Some users are not part of org" }); + + if (!orgMembers.length) return []; + + const existingMembers = await projectMembershipDAL.find({ + projectId, + $in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) } + }); + if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); + + const ghostUser = await projectDAL.findProjectGhostUser(projectId); + + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); + + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId }); + if (!bot) { + throw new BadRequestError({ + message: "Failed to find bot" + }); + } + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + + const newWsMembers = assignWorkspaceKeysToMembers({ + decryptKey: ghostUserLatestKey, + userPrivateKey: botPrivateKey, + members: orgMembers.map((membership) => ({ + orgMembershipId: membership.id, + projectMembershipRole, + userPublicKey: membership.user.publicKey + })) + }); + + const members: TProjectMemberships[] = []; + + const userIdsToExcludeForProjectKeyAddition = new Set( + await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId) + ); + const projectMemberships = await projectMembershipDAL.insertMany( + orgMembers.map(({ user }) => ({ + projectId, + userId: user.id + })), + tx + ); + await projectUserMembershipRoleDAL.insertMany( + projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectMembershipRole })), + tx + ); + + members.push(...projectMemberships); + + const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); + await projectKeyDAL.insertMany( + orgMembers + .filter(({ user }) => !userIdsToExcludeForProjectKeyAddition.has(user.id)) + .map(({ user, id }) => ({ + encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, + nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, + senderId: ghostUser.id, + receiverId: user.id, + projectId + })), + tx + ); + + if (sendEmails) { + const recipients = orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string); + + const appCfg = getConfig(); + + if (recipients.length) { + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical project invitation", + recipients: orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string), + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + } + } + + return members; + }; + + if (options.tx) { + return processTransaction(options.tx); + } + return projectMembershipDAL.transaction(processTransaction); + }; + + return { + addMembersToNonE2EEProject + }; +}; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 6996625f7..077838918 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -2,19 +2,12 @@ import { ForbiddenError } from "@casl/ability"; import ms from "ms"; -import { - ProjectMembershipRole, - ProjectVersion, - SecretKeyEncoding, - TableName, - TProjectMemberships -} from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectVersion, TableName } from "@app/db/schemas"; 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"; import { groupBy } from "@app/lib/fn"; @@ -23,13 +16,13 @@ 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"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; +import { addMembersToProject } from "./project-membership-fns"; import { ProjectUserMembershipTemporaryMode, TAddUsersToWorkspaceDTO, @@ -53,7 +46,7 @@ type TProjectMembershipServiceFactoryDep = { userGroupMembershipDAL: TUserGroupMembershipDALFactory; projectRoleDAL: Pick; orgDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; projectUserAdditionalPrivilegeDAL: Pick; @@ -247,116 +240,23 @@ export const projectMembershipServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); - const usernamesAndEmails = [...emails, ...usernames]; - - const orgMembers = await orgDAL.findOrgMembersByUsername(project.orgId, [ - ...new Set(usernamesAndEmails.map((element) => element.toLowerCase())) - ]); - - if (orgMembers.length !== usernamesAndEmails.length) - throw new BadRequestError({ message: "Some users are not part of org" }); - - if (!orgMembers.length) return []; - - const existingMembers = await projectMembershipDAL.find({ + const members = await addMembersToProject({ + orgDAL, + projectDAL, + projectMembershipDAL, + projectKeyDAL, + userGroupMembershipDAL, + projectBotDAL, + projectUserMembershipRoleDAL, + smtpService + }).addMembersToNonE2EEProject({ + emails, + usernames, projectId, - $in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) } - }); - if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); - - const ghostUser = await projectDAL.findProjectGhostUser(projectId); - - if (!ghostUser) { - throw new BadRequestError({ - message: "Failed to find sudo user" - }); - } - - const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); - - if (!ghostUserLatestKey) { - throw new BadRequestError({ - message: "Failed to find sudo user latest key" - }); - } - - const bot = await projectBotDAL.findOne({ projectId }); - if (!bot) { - throw new BadRequestError({ - message: "Failed to find bot" - }); - } - - const botPrivateKey = infisicalSymmetricDecrypt({ - keyEncoding: bot.keyEncoding as SecretKeyEncoding, - iv: bot.iv, - tag: bot.tag, - ciphertext: bot.encryptedPrivateKey + projectMembershipRole: ProjectMembershipRole.Member, + sendEmails }); - const newWsMembers = assignWorkspaceKeysToMembers({ - decryptKey: ghostUserLatestKey, - userPrivateKey: botPrivateKey, - members: orgMembers.map((membership) => ({ - orgMembershipId: membership.id, - projectMembershipRole: ProjectMembershipRole.Member, - userPublicKey: membership.user.publicKey - })) - }); - - const members: TProjectMemberships[] = []; - - const userIdsToExcludeForProjectKeyAddition = new Set( - await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId) - ); - - await projectMembershipDAL.transaction(async (tx) => { - const projectMemberships = await projectMembershipDAL.insertMany( - orgMembers.map(({ user }) => ({ - projectId, - userId: user.id - })), - tx - ); - await projectUserMembershipRoleDAL.insertMany( - projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), - tx - ); - - members.push(...projectMemberships); - - const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); - await projectKeyDAL.insertMany( - orgMembers - .filter(({ user }) => !userIdsToExcludeForProjectKeyAddition.has(user.id)) - .map(({ user, id }) => ({ - encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, - nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, - senderId: ghostUser.id, - receiverId: user.id, - projectId - })), - tx - ); - }); - - if (sendEmails) { - const recipients = orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string); - - const appCfg = getConfig(); - - if (recipients.length) { - await smtpService.sendMail({ - template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical project invitation", - recipients: orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string), - substitutions: { - workspaceName: project.name, - callback_url: `${appCfg.SITE_URL}/login` - } - }); - } - } return members; }; diff --git a/backend/src/services/project-role/project-role-fns.ts b/backend/src/services/project-role/project-role-fns.ts new file mode 100644 index 000000000..c465715a7 --- /dev/null +++ b/backend/src/services/project-role/project-role-fns.ts @@ -0,0 +1,52 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; +import { + projectAdminPermissions, + projectMemberPermissions, + projectNoAccessPermissions, + projectViewerPermission +} from "@app/ee/services/permission/project-permission"; + +export const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => { + return [ + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid + projectId, + name: "Admin", + slug: ProjectMembershipRole.Admin, + permissions: projectAdminPermissions, + description: "Full administrative access over a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response + projectId, + name: "Developer", + slug: ProjectMembershipRole.Member, + permissions: projectMemberPermissions, + description: "Limited read/write role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response + projectId, + name: "Viewer", + slug: ProjectMembershipRole.Viewer, + permissions: projectViewerPermission, + description: "Only read role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response + projectId, + name: "No Access", + slug: ProjectMembershipRole.NoAccess, + permissions: projectNoAccessPermissions, + description: "No access to any resources in the project", + createdAt: new Date(), + updatedAt: new Date() + } + ].filter(({ slug }) => !roleFilter || roleFilter.includes(slug)); +}; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 8c71a6e02..f60577516 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -5,13 +5,9 @@ import { ProjectMembershipRole } from "@app/db/schemas"; import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { - projectAdminPermissions, - projectMemberPermissions, - projectNoAccessPermissions, ProjectPermissionActions, ProjectPermissionSet, - ProjectPermissionSub, - projectViewerPermission + ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; @@ -20,6 +16,7 @@ import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/id import { TProjectDALFactory } from "../project/project-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "./project-role-dal"; +import { getPredefinedRoles } from "./project-role-fns"; import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types"; type TProjectRoleServiceFactoryDep = { @@ -37,51 +34,6 @@ const unpackPermissions = (permissions: unknown) => unpackRules((permissions || []) as PackRule>>[]) ); -const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => { - return [ - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid - projectId, - name: "Admin", - slug: ProjectMembershipRole.Admin, - permissions: projectAdminPermissions, - description: "Full administrative access over a project", - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response - projectId, - name: "Developer", - slug: ProjectMembershipRole.Member, - permissions: projectMemberPermissions, - description: "Limited read/write role in a project", - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response - projectId, - name: "Viewer", - slug: ProjectMembershipRole.Viewer, - permissions: projectViewerPermission, - description: "Only read role in a project", - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response - projectId, - name: "No Access", - slug: ProjectMembershipRole.NoAccess, - permissions: projectNoAccessPermissions, - description: "No access to any resources in the project", - createdAt: new Date(), - updatedAt: new Date() - } - ].filter(({ slug }) => !roleFilter || roleFilter.includes(slug)); -}; - export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService, diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index ce7f6324e..64500814f 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -279,6 +279,34 @@ export const projectDALFactory = (db: TDbClient) => { } }; + const findProjectWithOrg = async (projectId: string) => { + // we just need the project, and we need to include a new .organization field that includes the org from the orgId reference + + const project = await db(TableName.Project) + .where({ [`${TableName.Project}.id` as "id"]: projectId }) + + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) + + .select( + db.ref("id").withSchema(TableName.Organization).as("organizationId"), + db.ref("name").withSchema(TableName.Organization).as("organizationName") + ) + .select(selectAllTableCols(TableName.Project)) + .first(); + + if (!project) { + throw new BadRequestError({ message: "Project not found" }); + } + + return { + ...ProjectsSchema.parse(project), + organization: { + id: project.organizationId, + name: project.organizationName + } + }; + }; + return { ...projectOrm, findAllProjects, @@ -288,6 +316,7 @@ export const projectDALFactory = (db: TDbClient) => { findProjectById, findProjectByFilter, findProjectBySlug, + findProjectWithOrg, checkProjectUpgradeStatus }; }; diff --git a/backend/src/services/project/project-fns.ts b/backend/src/services/project/project-fns.ts index d6b010e0b..cb836d932 100644 --- a/backend/src/services/project/project-fns.ts +++ b/backend/src/services/project/project-fns.ts @@ -1,5 +1,6 @@ import crypto from "crypto"; +import { ProjectVersion, TProjects } from "@app/db/schemas"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -53,6 +54,16 @@ export const createProjectKey = ({ publicKey, privateKey, plainProjectKey }: TCr return { key: encryptedProjectKey, iv: encryptedProjectKeyIv }; }; +export const verifyProjectVersions = (projects: Pick[], version: ProjectVersion) => { + for (const project of projects) { + if (project.version !== version) { + return false; + } + } + + return true; +}; + export const getProjectKmsCertificateKeyId = async ({ projectId, projectDAL, diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index a7111d784..3e8f7582e 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -10,6 +10,7 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; @@ -30,6 +31,8 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; +import { getPredefinedRoles } from "../project-role/project-role-fns"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectDALFactory } from "./project-dal"; @@ -44,6 +47,7 @@ import { TListProjectCasDTO, TListProjectCertificateTemplatesDTO, TListProjectCertsDTO, + TListProjectsDTO, TLoadProjectKmsBackupDTO, TToggleProjectAutoCapitalizationDTO, TUpdateAuditLogsRetentionDTO, @@ -84,6 +88,7 @@ type TProjectServiceFactoryDep = { orgDAL: Pick; keyStore: Pick; projectBotDAL: Pick; + projectRoleDAL: Pick; kmsService: Pick< TKmsServiceFactory, | "updateProjectSecretManagerKmsKey" @@ -112,6 +117,7 @@ export const projectServiceFactory = ({ projectEnvDAL, licenseService, projectUserMembershipRoleDAL, + projectRoleDAL, identityProjectMembershipRoleDAL, certificateAuthorityDAL, certificateDAL, @@ -389,8 +395,34 @@ export const projectServiceFactory = ({ return deletedProject; }; - const getProjects = async (actorId: string) => { + const getProjects = async ({ actorId, includeRoles, actorAuthMethod, actorOrgId }: TListProjectsDTO) => { const workspaces = await projectDAL.findAllProjects(actorId); + + if (includeRoles) { + const { permission } = await permissionService.getUserOrgPermission(actorId, actorOrgId, actorAuthMethod); + + // `includeRoles` is specifically used by organization admins when inviting new users to the organizations to avoid looping redundant api calls. + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + const customRoles = await projectRoleDAL.find({ + $in: { + projectId: workspaces.map((workspace) => workspace.id) + } + }); + + const workspaceMappedToRoles = groupBy(customRoles, (role) => role.projectId); + + const workspacesWithRoles = await Promise.all( + workspaces.map(async (workspace) => { + return { + ...workspace, + roles: [...(workspaceMappedToRoles[workspace.id] || []), ...getPredefinedRoles(workspace.id)] + }; + }) + ); + + return workspacesWithRoles; + } + return workspaces; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index ee2f1aea9..c0ef2579e 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -75,6 +75,10 @@ export type TDeleteProjectDTO = { actorOrgId: string | undefined; } & Omit; +export type TListProjectsDTO = { + includeRoles: boolean; +} & Omit; + export type TUpgradeProjectDTO = { userPrivateKey: string; } & TProjectPermission; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 94d84b98c..d0e1e0774 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -25,6 +25,7 @@ export enum SmtpTemplates { UnlockAccount = "unlockAccount.handlebars", AccessApprovalRequest = "accessApprovalRequest.handlebars", AccessSecretRequestBypassed = "accessSecretRequestBypassed.handlebars", + SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview.handlebars", HistoricalSecretList = "historicalSecretLeakIncident.handlebars", NewDeviceJoin = "newDevice.handlebars", OrgInvite = "organizationInvitation.handlebars", diff --git a/backend/src/services/smtp/templates/organizationInvitation.handlebars b/backend/src/services/smtp/templates/organizationInvitation.handlebars index 024fca132..3ee16ee37 100644 --- a/backend/src/services/smtp/templates/organizationInvitation.handlebars +++ b/backend/src/services/smtp/templates/organizationInvitation.handlebars @@ -9,7 +9,7 @@

Join your organization on Infisical

{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization — {{organizationName}}

- Join now + Join now

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

diff --git a/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars b/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars new file mode 100644 index 000000000..9dd6fe747 --- /dev/null +++ b/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars @@ -0,0 +1,22 @@ + + + + + + Secret Change Approval Request + + + +

Hi {{firstName}},

+

New secret change requests are pending review.

+
+

You have a secret change request pending your review in project "{{projectName}}", in the "{{organizationName}}" + organization.

+ +

+ View the request and approve or deny it + here. +

+ + + \ No newline at end of file diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index b168fe5d7..ddeb24211 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -100,7 +100,9 @@ export type TIntegrationCreatedEvent = { export type TUserOrgInvitedEvent = { event: PostHogEventTypes.UserOrgInvitation; properties: { - inviteeEmail: string; + inviteeEmails: string[]; + projectIds?: string[]; + organizationRoleSlug?: string; }; }; diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index 4717e0784..f66197517 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "encoding/json" "os" + "slices" "strings" "time" @@ -152,6 +153,28 @@ var loginCmd = &cobra.Command{ DisableFlagsInUseLine: true, Run: func(cmd *cobra.Command, args []string) { + clearSelfHostedDomains, err := cmd.Flags().GetBool("clear-domains") + if err != nil { + util.HandleError(err) + } + + if clearSelfHostedDomains { + infisicalConfig, err := util.GetConfigFile() + if err != nil { + util.HandleError(err) + } + + infisicalConfig.Domains = []string{} + err = util.WriteConfigFile(&infisicalConfig) + + if err != nil { + util.HandleError(err) + } + + fmt.Println("Cleared all self-hosted domains from the config file") + return + } + infisicalClient := infisicalSdk.NewInfisicalClient(infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, UserAgent: api.USER_AGENT, @@ -464,6 +487,7 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials) { func init() { rootCmd.AddCommand(loginCmd) + loginCmd.Flags().Bool("clear-domains", false, "clear all self-hosting domains from the config file") loginCmd.Flags().BoolP("interactive", "i", false, "login via the command line") loginCmd.Flags().String("method", "user", "login method [user, universal-auth]") loginCmd.Flags().Bool("plain", false, "only output the token without any formatting") @@ -499,10 +523,12 @@ func DomainOverridePrompt() (bool, error) { } func askForDomain() error { - //query user to choose between Infisical cloud or self hosting + + // query user to choose between Infisical cloud or self hosting const ( INFISICAL_CLOUD = "Infisical Cloud" SELF_HOSTING = "Self Hosting" + ADD_NEW_DOMAIN = "Add a new domain" ) options := []string{INFISICAL_CLOUD, SELF_HOSTING} @@ -524,6 +550,36 @@ func askForDomain() error { return nil } + infisicalConfig, err := util.GetConfigFile() + if err != nil { + return fmt.Errorf("askForDomain: unable to get config file because [err=%s]", err) + } + + if infisicalConfig.Domains != nil && len(infisicalConfig.Domains) > 0 { + // If domains are present in the config, let the user select from the list or select to add a new domain + + items := append(infisicalConfig.Domains, ADD_NEW_DOMAIN) + + prompt := promptui.Select{ + Label: "Which domain would you like to use?", + Items: items, + Size: 5, + } + + _, selectedOption, err := prompt.Run() + if err != nil { + return err + } + + if selectedOption != ADD_NEW_DOMAIN { + config.INFISICAL_URL = fmt.Sprintf("%s/api", selectedOption) + config.INFISICAL_LOGIN_URL = fmt.Sprintf("%s/login", selectedOption) + return nil + + } + + } + urlValidation := func(input string) error { _, err := url.ParseRequestURI(input) if err != nil { @@ -542,12 +598,23 @@ func askForDomain() error { if err != nil { return err } - //trimmed the '/' from the end of the self hosting url + + // Trimmed the '/' from the end of the self hosting url, and set the api & login url domain = strings.TrimRight(domain, "/") - //set api and login url config.INFISICAL_URL = fmt.Sprintf("%s/api", domain) config.INFISICAL_LOGIN_URL = fmt.Sprintf("%s/login", domain) - //return nil + + // Write the new domain to the config file, to allow the user to select it in the future if needed + // First check if infiscialConfig.Domains already includes the domain, if it does, do not add it again + if !slices.Contains(infisicalConfig.Domains, domain) { + infisicalConfig.Domains = append(infisicalConfig.Domains, domain) + err = util.WriteConfigFile(&infisicalConfig) + + if err != nil { + return fmt.Errorf("askForDomain: unable to write domains to config file because [err=%s]", err) + } + } + return nil } diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index a98f02bae..c4bbd0175 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -16,6 +16,7 @@ type ConfigFile struct { LoggedInUsers []LoggedInUser `json:"loggedInUsers,omitempty"` VaultBackendType string `json:"vaultBackendType,omitempty"` VaultBackendPassphrase string `json:"vaultBackendPassphrase,omitempty"` + Domains []string `json:"domains,omitempty"` } type LoggedInUser struct { diff --git a/docs/api-reference/endpoints/certificate-authorities/crl.mdx b/docs/api-reference/endpoints/certificate-authorities/crl.mdx index a7b7755de..428c3377e 100644 --- a/docs/api-reference/endpoints/certificate-authorities/crl.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/crl.mdx @@ -1,4 +1,4 @@ --- -title: "Retrieve CRL" -openapi: "GET /api/v1/pki/ca/{caId}/crl" +title: "List CRLs" +openapi: "GET /api/v1/pki/ca/{caId}/crls" --- diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index ab6f4df59..41a5cb8c9 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -151,18 +151,24 @@ In the following steps, we explore how to revoke a X.509 certificate under a CA In order to check the revocation status of a certificate, you can check it - against the CRL of a CA by selecting the **View CRL** option under the - issuing CA and downloading the CRL file. + against the CRL of a CA by heading to its Issuing CA and downloading the CRL. ![pki view crl](/images/platform/pki/ca-crl.png) - ![pki download crl](/images/platform/pki/ca-crl-modal.png) - To verify a certificate against the downloaded CRL with OpenSSL, you can use the following command: ```bash openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem +``` + +Note that you can also obtain the CRL from the certificate itself by +referencing the CRL distribution point extension on the certificate itself. + +To check a certificate against the CRL distribution point specified within it with OpenSSL, you can use the following command: + +```bash +openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem ``` @@ -197,21 +203,25 @@ openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA. - To obtain the CRL of the CA, make an API request to the [Get CRL](/api-reference/endpoints/certificate-authorities/crl) API endpoint. + To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crls) API endpoint. ### Sample request ```bash Request - curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//crl' \ + curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//crls' \ --header 'Authorization: Bearer ' ``` ### Sample response ```bash Response - { - crl: "..." - } + [ + { + id: "...", + crl: "..." + }, + ... + ] ``` To verify a certificate against the CRL with OpenSSL, you can use the following command: diff --git a/docs/documentation/platform/pki/private-ca.mdx b/docs/documentation/platform/pki/private-ca.mdx index aff6fae05..0baa13abb 100644 --- a/docs/documentation/platform/pki/private-ca.mdx +++ b/docs/documentation/platform/pki/private-ca.mdx @@ -327,10 +327,10 @@ the certificate back to the intermediate CA. At the moment, Infisical only supports CA renewal via same key pair. We anticipate supporting CA renewal via new key pair in the coming month. - + Yes. You may obtain a CSR from the Intermediate CA and use it to generate a - certificate from your external Root CA. The certificate, along with the Root - CA certificate, can be imported back to the Intermediate CA as part of the - CA installation step. + certificate from your external CA. The certificate, along with the external + CA certificate chain, can be imported back to the Intermediate CA as part of + the CA installation step. diff --git a/docs/images/platform/pki/ca-crl-modal.png b/docs/images/platform/pki/ca-crl-modal.png deleted file mode 100644 index af26b1aca..000000000 Binary files a/docs/images/platform/pki/ca-crl-modal.png and /dev/null differ diff --git a/docs/images/platform/pki/ca-crl.png b/docs/images/platform/pki/ca-crl.png index 4794034a1..efe7d3b4a 100644 Binary files a/docs/images/platform/pki/ca-crl.png and b/docs/images/platform/pki/ca-crl.png differ diff --git a/docs/mint.json b/docs/mint.json index 0916277eb..2e408abb6 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -693,7 +693,7 @@ "api-reference/endpoints/certificate-authorities/import-cert", "api-reference/endpoints/certificate-authorities/issue-cert", "api-reference/endpoints/certificate-authorities/sign-cert", - "api-reference/endpoints/certificate-authorities/crl" + "api-reference/endpoints/certificate-authorities/crls" ] }, { diff --git a/docs/sdks/languages/python.mdx b/docs/sdks/languages/python.mdx index 7f47b70da..3e066fc79 100644 --- a/docs/sdks/languages/python.mdx +++ b/docs/sdks/languages/python.mdx @@ -1,10 +1,11 @@ --- title: "Infisical Python SDK" sidebarTitle: "Python" +url: "https://github.com/Infisical/python-sdk-official?tab=readme-ov-file#infisical-python-sdk" icon: "python" --- -If you're working with Python, the official [infisical-python](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) package is the easiest way to fetch and work with secrets for your application. +{/* If you're working with Python, the official [infisical-python](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) package is the easiest way to fetch and work with secrets for your application. - [PyPi Package](https://pypi.org/project/infisical-python/) - [Github Repository](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) @@ -529,4 +530,4 @@ decryptedString = client.decryptSymmetric(decryptOptions) #### Returns (string) -`plaintext` (string): The decrypted plaintext. +`plaintext` (string): The decrypted plaintext. */} diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index 4ab664680..e502fdd4e 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -13,7 +13,7 @@ From local development to production, Infisical SDKs provide the easiest way for Manage secrets for your Node application on demand - + Manage secrets for your Python application on demand diff --git a/frontend/next.config.js b/frontend/next.config.js index d1d5876ed..9b9db1346 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -2,7 +2,7 @@ const path = require("path"); const ContentSecurityPolicy = ` default-src 'self'; - script-src 'self' https://*.posthog.com https://*.*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval'; + script-src 'self' https://*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval'; style-src 'self' https://rsms.me 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; child-src https://api.stripe.com; frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com; diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index cf90ef659..1fd7e7789 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -33,7 +33,8 @@ const integrationSlugNameMapping: Mapping = { windmill: "Windmill", "gcp-secret-manager": "GCP Secret Manager", "hasura-cloud": "Hasura Cloud", - rundeck: "Rundeck" + rundeck: "Rundeck", + "azure-devops": "Azure DevOps" }; const envMapping: Mapping = { diff --git a/frontend/src/components/basic/dialog/AddUserDialog.tsx b/frontend/src/components/basic/dialog/AddUserDialog.tsx deleted file mode 100644 index dd2aede0a..000000000 --- a/frontend/src/components/basic/dialog/AddUserDialog.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { Fragment } from "react"; -import { Dialog, Transition } from "@headlessui/react"; - -import Button from "../buttons/Button"; -import InputField from "../InputField"; - -type Props = { - isOpen: boolean; - closeModal: () => void; - submitModal: (email: string) => void; - email: string; - setEmail: (email: string) => void; - orgName: string; -}; - -const AddUserDialog = ({ isOpen, closeModal, submitModal, email, setEmail, orgName }: Props) => { - const submit = () => { - submitModal(email); - }; - - return ( -
- - - -
- - -
-
- - - - Invite others to {orgName} - -
-

- An invite is specific to an email address and expires after 1 day. For - security reasons, you will need to separately add members to projects. -

-
-
- -
-
-
-
- {/* - - Unleash Infisical's Full Power - -
-

- You have exceeded the number of members in a free organization. -

-

- Upgrade now and get access to adding more members, as well as to other powerful enhancements. -

-
-
- - -
-
*/} -
-
-
-
-
-
- ); -}; - -export default AddUserDialog; diff --git a/frontend/src/components/signup/TeamInviteStep.tsx b/frontend/src/components/signup/TeamInviteStep.tsx index a06fc3568..934935a42 100644 --- a/frontend/src/components/signup/TeamInviteStep.tsx +++ b/frontend/src/components/signup/TeamInviteStep.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; -import { useAddUserToOrg } from "@app/hooks/api"; +import { useAddUsersToOrg } from "@app/hooks/api"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -17,7 +17,7 @@ export default function TeamInviteStep(): JSX.Element { const [emails, setEmails] = useState(""); const { data: serverDetails } = useFetchServerStatus(); - const { mutateAsync } = useAddUserToOrg(); + const { mutateAsync } = useAddUsersToOrg(); const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const); // Redirect user to the getting started page @@ -31,8 +31,9 @@ export default function TeamInviteStep(): JSX.Element { .map((email) => email.trim()) .map(async (email) => { mutateAsync({ - inviteeEmail: email, - organizationId: String(localStorage.getItem("orgData.id")) + inviteeEmails: [email], + organizationId: String(localStorage.getItem("orgData.id")), + organizationRoleSlug: "member" }); }); diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 1b4dff1f0..3664e8e96 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -93,6 +93,7 @@ export type CompleteAccountDTO = { salt: string; verifier: string; password: string; + tokenMetadata?: string; }; export type CompleteAccountSignupDTO = CompleteAccountDTO & { diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index 980624f32..5c0ff4caa 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -8,4 +8,4 @@ export { useSignIntermediate, useUpdateCa } from "./mutations"; -export { useGetCaById, useGetCaCert, useGetCaCerts, useGetCaCrl, useGetCaCsr } from "./queries"; +export { useGetCaById, useGetCaCert, useGetCaCerts, useGetCaCrls, useGetCaCsr } from "./queries"; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index d8f809565..996043f19 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -7,6 +7,7 @@ import { TCertificateAuthority } from "./types"; export const caKeys = { getCaById: (caId: string) => [{ caId }, "ca"], getCaCerts: (caId: string) => [{ caId }, "ca-cert"], + getCaCrls: (caId: string) => [{ caId }, "ca-crls"], getCaCert: (caId: string) => [{ caId }, "ca-cert"], getCaCsr: (caId: string) => [{ caId }, "ca-csr"], getCaCrl: (caId: string) => [{ caId }, "ca-crl"], @@ -74,16 +75,17 @@ export const useGetCaCsr = (caId: string) => { }); }; -export const useGetCaCrl = (caId: string) => { +export const useGetCaCrls = (caId: string) => { return useQuery({ - queryKey: caKeys.getCaCrl(caId), + queryKey: caKeys.getCaCrls(caId), queryFn: async () => { - const { - data: { crl } - } = await apiRequest.get<{ - crl: string; - }>(`/api/v1/pki/ca/${caId}/crl`); - return crl; + const { data } = await apiRequest.get< + { + id: string; + crl: string; + }[] + >(`/api/v1/pki/ca/${caId}/crls`); + return data; }, enabled: Boolean(caId) }); diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 45ac90a84..efb5ad7fb 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -120,16 +120,22 @@ const fetchIntegrationAuthById = async (integrationAuthId: string) => { const fetchIntegrationAuthApps = async ({ integrationAuthId, teamId, + azureDevOpsOrgName, workspaceSlug }: { integrationAuthId: string; teamId?: string; + azureDevOpsOrgName?: string; workspaceSlug?: string; }) => { const params: Record = {}; if (teamId) { params.teamId = teamId; } + if (azureDevOpsOrgName) { + params.azureDevOpsOrgName = azureDevOpsOrgName; + } + if (workspaceSlug) { params.workspaceSlug = workspaceSlug; } @@ -452,10 +458,12 @@ export const useGetIntegrationAuthById = (integrationAuthId: string) => { export const useGetIntegrationAuthApps = ({ integrationAuthId, teamId, + azureDevOpsOrgName, workspaceSlug }: { integrationAuthId: string; teamId?: string; + azureDevOpsOrgName?: string; workspaceSlug?: string; }) => { return useQuery({ @@ -464,6 +472,7 @@ export const useGetIntegrationAuthApps = ({ fetchIntegrationAuthApps({ integrationAuthId, teamId, + azureDevOpsOrgName, workspaceSlug }), enabled: true diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 865d28789..77e82e4fa 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -47,7 +47,7 @@ export const roleQueryKeys = { ["user-project-permissions", { workspaceId }] as const }; -const getProjectRoles = async (projectId: string) => { +export const getProjectRoles = async (projectId: string) => { const { data } = await apiRequest.get<{ roles: Array> }>( `/api/v1/workspace/${projectId}/roles` ); diff --git a/frontend/src/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx index 2ad79932b..ceebd3493 100644 --- a/frontend/src/hooks/api/secretApproval/mutation.tsx +++ b/frontend/src/hooks/api/secretApproval/mutation.tsx @@ -9,7 +9,15 @@ export const useCreateSecretApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateSecretPolicyDTO>({ - mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath, name, enforcementLevel }) => { + mutationFn: async ({ + environment, + workspaceId, + approvals, + approvers, + secretPath, + name, + enforcementLevel + }) => { const { data } = await apiRequest.post("/api/v1/secret-approvals", { environment, workspaceId, diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 2026cede1..ed9e5cc47 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -6,7 +6,7 @@ export { } from "./mutation"; export { fetchOrgUsers, - useAddUserToOrg, + useAddUsersToOrg, useCreateAPIKey, useDeleteAPIKey, useDeleteMe, @@ -26,4 +26,5 @@ export { useRevokeMySessions, useUpdateMfaEnabled, useUpdateOrgMembership, - useUpdateUserAuthMethods} from "./queries"; + useUpdateUserAuthMethods +} from "./queries"; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 4629ce4e6..ead567bee 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -157,12 +157,15 @@ export const useGetOrgUsers = (orgId: string) => // mutation // TODO(akhilmhdh): move all mutation to mutation file -export const useAddUserToOrg = () => { +export const useAddUsersToOrg = () => { const queryClient = useQueryClient(); type Response = { data: { message: string; - completeInviteLink: string | undefined; + completeInviteLinks?: { + email: string; + link: string; + }[]; }; }; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 825d468da..550159fb4 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -149,7 +149,10 @@ export type DeletOrgMembershipDTO = { }; export type AddUserToOrgDTO = { - inviteeEmail: string; + inviteeEmails: string[]; + projectIds?: string[]; + projectRoleSlug?: string; + organizationRoleSlug: string; organizationId: string; }; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 8f1b065da..9ca3bbf34 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -138,8 +138,12 @@ export const useGetUpgradeProjectStatus = ({ }); }; -const fetchUserWorkspaces = async () => { - const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace"); +const fetchUserWorkspaces = async (includeRoles?: boolean) => { + const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace", { + params: { + includeRoles + } + }); return data.workspaces; }; @@ -171,8 +175,8 @@ export const useGetWorkspaceById = ( }); }; -export const useGetUserWorkspaces = () => - useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces); +export const useGetUserWorkspaces = (includeRoles?: boolean) => + useQuery(workspaceKeys.getAllUserWorkspace, () => fetchUserWorkspaces(includeRoles)); const fetchUserWorkspaceMemberships = async (orgId: string) => { const { data } = await apiRequest.get>( diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 51bb08e3d..efb31b330 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -1,3 +1,5 @@ +import { TProjectRole } from "../roles/types"; + export enum ProjectVersion { V1 = 1, V2 = 2, @@ -22,6 +24,8 @@ export type Workspace = { auditLogsRetentionDays: number; slug: string; createdAt: string; + + roles?: TProjectRole[]; }; export type WorkspaceEnv = { diff --git a/frontend/src/hooks/useToggle.tsx b/frontend/src/hooks/useToggle.tsx index 83d73bcb1..ecc243ade 100644 --- a/frontend/src/hooks/useToggle.tsx +++ b/frontend/src/hooks/useToggle.tsx @@ -8,6 +8,7 @@ type UseToggleReturn = [ on: VoidFn; off: VoidFn; toggle: VoidFn; + timedToggle: (timeout?: number) => void; } ]; @@ -26,5 +27,13 @@ export const useToggle = (initialState = false): UseToggleReturn => { setValue((prev) => (typeof isOpen === "boolean" ? isOpen : !prev)); }, []); - return [value, { on, off, toggle }]; + const timedToggle = useCallback((timeout = 2000) => { + setValue((prev) => !prev); + + setTimeout(() => { + setValue(false); + }, timeout); + }, []); + + return [value, { on, off, toggle, timedToggle }]; }; diff --git a/frontend/src/pages/integrations/azure-devops/authorize.tsx b/frontend/src/pages/integrations/azure-devops/authorize.tsx new file mode 100644 index 000000000..989b1a17a --- /dev/null +++ b/frontend/src/pages/integrations/azure-devops/authorize.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; + +import { useSaveIntegrationAccessToken } from "@app/hooks/api"; + +import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2"; + +export default function AzureDevopsCreateIntegrationPage() { + const router = useRouter(); + const { mutateAsync } = useSaveIntegrationAccessToken(); + + const [apiKey, setApiKey] = useState(""); + const [devopsOrgName, setDevopsOrgName] = useState(""); + const [apiKeyErrorText, setApiKeyErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(""); + if (apiKey.length === 0) { + setApiKeyErrorText("API Key cannot be blank"); + return; + } + + setIsLoading(true); + + localStorage.setItem("azure-devops-org-name", devopsOrgName); + + const integrationAuth = await mutateAsync({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "azure-devops", + accessToken: btoa(`:${apiKey}`) // This is a base64 encoding of the API key without any username + }); + + setIsLoading(false); + + router.push(`/integrations/azure-devops/create?integrationAuthId=${integrationAuth.id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + AzureDevops Integration + + setApiKey(e.target.value)} /> + + + setDevopsOrgName(e.target.value)} + /> + + + + +
+ ); +} + +AzureDevopsCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/azure-devops/create.tsx b/frontend/src/pages/integrations/azure-devops/create.tsx new file mode 100644 index 000000000..d34636106 --- /dev/null +++ b/frontend/src/pages/integrations/azure-devops/create.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { useCreateIntegration } from "@app/hooks/api"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "../../../components/v2"; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById +} from "../../../hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "../../../hooks/api/workspace"; + +export default function AzureDevopsCreateIntegrationPage() { + const router = useRouter(); + const { mutateAsync } = useCreateIntegration(); + + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: (integrationAuthId as string) ?? "", + azureDevOpsOrgName: localStorage.getItem("azure-devops-org-name") ?? "" + }); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [secretPath, setSecretPath] = useState("/"); + const [targetApp, setTargetApp] = useState(""); + + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp("none"); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?.id) return; + + setIsLoading(true); + + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + app: localStorage.getItem("azure-devops-org-name") || "", + appId: targetApp, + sourceEnvironment: selectedSourceEnvironment, + secretPath + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && + workspace && + selectedSourceEnvironment && + integrationAuthApps && + targetApp ? ( +
+ + AzureDevops Integration + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + + +
+ ) : ( +
+ ); +} + +AzureDevopsCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx index 349952ba5..cf953664d 100644 --- a/frontend/src/pages/login/select-organization.tsx +++ b/frontend/src/pages/login/select-organization.tsx @@ -38,6 +38,7 @@ export default function LoginPage() { const { user, isLoading: userLoading } = useUser(); const queryParams = new URLSearchParams(window.location.search); + const callbackPort = queryParams.get("callback_port"); const logout = useLogoutUser(true); const handleLogout = useCallback(async () => { @@ -52,8 +53,6 @@ export default function LoginPage() { const handleSelectOrganization = useCallback( async (organization: Organization) => { - const callbackPort = queryParams.get("callback_port"); - if (organization.authEnforced) { // org has an org-level auth method enabled (e.g. SAML) // -> logout + redirect to SAML SSO @@ -116,9 +115,8 @@ export default function LoginPage() { [selectOrg] ); - useEffect(() => { + const handleCliRedirect = useCallback(() => { const authToken = getAuthToken(); - const callbackPort = queryParams.get("callback_port"); if (authToken && !callbackPort) { const decodedJwt = jwt_decode(authToken) as any; @@ -131,13 +129,27 @@ export default function LoginPage() { if (!isLoggedIn()) { router.push("/login"); } + }, []); + + useEffect(() => { + if (callbackPort) { + handleCliRedirect(); + } }, [router]); // Case: User has no organizations. // This can happen if the user was previously a member, but the organization was deleted or the user was removed. useEffect(() => { - if (!organizations.isLoading && organizations.data?.length === 0) { + if (organizations.isLoading || !organizations.data) return; + + if (organizations.data.length === 0) { router.push("/org/none"); + } else if (organizations.data.length === 1) { + if (callbackPort) { + handleCliRedirect(); + } else { + handleSelectOrganization(organizations.data[0]); + } } }, [organizations.isLoading, organizations.data]); diff --git a/frontend/src/pages/signupinvite.tsx b/frontend/src/pages/signupinvite.tsx index 725dc6113..713a64eb3 100644 --- a/frontend/src/pages/signupinvite.tsx +++ b/frontend/src/pages/signupinvite.tsx @@ -64,6 +64,10 @@ export default function SignupInvite() { const email = (parsedUrl.to as string)?.replace(" ", "+").trim(); const { config } = useServerConfig(); + const queryParams = new URLSearchParams(window.location.search); + + const metadata = queryParams.get("metadata") || undefined; + const { mutateAsync: selectOrganization } = useSelectOrganization(); useEffect(() => { @@ -160,7 +164,8 @@ export default function SignupInvite() { encryptedPrivateKeyIV, encryptedPrivateKeyTag, salt: result.salt, - verifier: result.verifier + verifier: result.verifier, + tokenMetadata: metadata }); // unset temporary signup JWT token and set JWT token diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 1aee4c656..69d603f20 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -131,6 +131,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "rundeck": link = `${window.location.origin}/integrations/rundeck/authorize`; break; + case "azure-devops": + link = `${window.location.origin}/integrations/azure-devops/authorize`; + break; default: break; } diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index aedeb369a..3e9720718 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -1,65 +1,144 @@ import { Controller, useForm } from "react-hook-form"; -import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { + faCheckCircle, + faChevronDown, + faExclamationCircle +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, IconButton, Input, Modal, ModalContent } from "@app/components/v2"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FormControl, + Modal, + ModalContent, + Select, + SelectItem, + TextArea, + Tooltip +} from "@app/components/v2"; import { useOrganization } from "@app/context"; -import { useToggle } from "@app/hooks"; -import { useAddUserToOrg, useFetchServerStatus } from "@app/hooks/api"; +import { + useAddUsersToOrg, + useFetchServerStatus, + useGetOrgRoles, + useGetUserWorkspaces +} from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const addMemberFormSchema = yup.object({ - email: yup.string().email().required().label("Email").trim().lowercase() +import { OrgInviteLink } from "./OrgInviteLink"; + +const DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG = "member"; + +const EmailSchema = z.string().email().min(1).trim().toLowerCase(); + +const addMemberFormSchema = z.object({ + emails: z.string().min(1).trim().toLowerCase(), + projectIds: z.array(z.string().min(1).trim().toLowerCase()).default([]), + projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG), + organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG) }); -type TAddMemberForm = yup.InferType; +type TAddMemberForm = z.infer; type Props = { popUp: UsePopUpState<["addMember"]>; handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void; - completeInviteLink: string; - setCompleteInviteLink: (link: string) => void; + completeInviteLinks: Array<{ + email: string; + link: string; + }> | null; + setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void; }; export const AddOrgMemberModal = ({ popUp, handlePopUpToggle, - completeInviteLink, - setCompleteInviteLink + completeInviteLinks, + setCompleteInviteLinks }: Props) => { - const { currentOrg } = useOrganization(); + const { data: organizationRoles } = useGetOrgRoles(currentOrg?.id ?? ""); const { data: serverDetails } = useFetchServerStatus(); - const { mutateAsync: addUserMutateAsync } = useAddUserToOrg(); - - const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false); + const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg(); + const { data: projects } = useGetUserWorkspaces(true); const { control, handleSubmit, + watch, reset, formState: { isSubmitting } - } = useForm({ resolver: yupResolver(addMemberFormSchema) }); + } = useForm({ resolver: zodResolver(addMemberFormSchema) }); - const onAddMember = async ({ email }: TAddMemberForm) => { + const selectedProjectIds = watch("projectIds", []); + + const onAddMembers = async ({ + emails, + organizationRoleSlug, + projectIds, + projectRoleSlug + }: TAddMemberForm) => { if (!currentOrg?.id) return; + const selectedProjects = projects?.filter((project) => projectIds.includes(String(project.id))); + + if (selectedProjects?.length) { + // eslint-disable-next-line no-restricted-syntax + for (const project of selectedProjects) { + if (project.version !== ProjectVersion.V3) { + createNotification({ + type: "error", + text: `Cannot add users to project "${project.name}" because it's incompatible. Please upgrade the project.` + }); + return; + } + } + } + try { - const { data } = await addUserMutateAsync({ + const parsedEmails = emails + .replace(/\s/g, "") + .split(",") + .map((email) => { + if (EmailSchema.safeParse(email).success) { + return email.trim(); + } + + return null; + }); + + if (parsedEmails.includes(null)) { + createNotification({ + text: "Invalid email addresses provided.", + type: "error" + }); + return; + } + + const { data } = await addUsersMutateAsync({ organizationId: currentOrg?.id, - inviteeEmail: email + inviteeEmails: emails.split(",").map((email) => email.trim()), + organizationRoleSlug, + projectIds, + projectRoleSlug }); - setCompleteInviteLink(data?.completeInviteLink ?? ""); + setCompleteInviteLinks(data?.completeInviteLinks ?? null); // only show this notification when email is configured. // A [completeInviteLink] will not be sent if smtp is configured - if (!data.completeInviteLink) { + if (!data.completeInviteLinks) { createNotification({ text: "Successfully invited user to the organization.", type: "success" @@ -80,47 +159,196 @@ export const AddOrgMemberModal = ({ reset(); }; - const copyTokenToClipboard = () => { - navigator.clipboard.writeText(completeInviteLink as string); - setInviteLinkCopied.on(); - }; - return ( { handlePopUpToggle("addMember", isOpen); - setCompleteInviteLink(""); + setCompleteInviteLinks(null); }} > - {!completeInviteLink && ( -
- An invite is specific to an email address and expires after 1 day. -
- For security reasons, you will need to separately add members to projects. -
+ {!completeInviteLinks && ( +
An invite is specific to an email address and expires after 1 day.
)} - {completeInviteLink && + {completeInviteLinks && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
} > - {!completeInviteLink && ( -
+ {!completeInviteLinks && ( + ( - - + +