From c7572a3374201681dcb6cee8136c0b103a3b0269 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Sat, 13 Jan 2024 22:55:43 +0530 Subject: [PATCH] feat: completed license server integration --- backend-pg/package-lock.json | 25 +- backend-pg/package.json | 3 +- backend-pg/src/@types/fastify.d.ts | 4 + backend-pg/src/@types/knex.d.ts | 8 + .../db/migrations/20231212110939_project.ts | 2 +- .../20231225072545_service-token.ts | 2 +- .../migrations/20240113103743_trusted-ip.ts | 26 + backend-pg/src/db/schemas/index.ts | 1 + backend-pg/src/db/schemas/models.ts | 1 + backend-pg/src/db/schemas/trusted-ips.ts | 24 + backend-pg/src/ee/routes/v1/index.ts | 10 +- backend-pg/src/ee/routes/v1/license-router.ts | 365 +++++++++++++ .../src/ee/routes/v1/trusted-ip-router.ts | 158 ++++++ .../ee/services/audit-log/audit-log-queue.ts | 23 +- .../src/ee/services/license/licence-fns.ts | 97 ++++ .../src/ee/services/license/license-dal.ts | 25 +- .../ee/services/license/license-service.ts | 511 +++++++++++++++++- .../src/ee/services/license/license-types.ts | 72 +++ .../saml-config/saml-config-service.ts | 19 +- .../secret-rotation-service.ts | 24 +- .../secret-snapshot-service.ts | 10 +- .../ee/services/trusted-ip/trusted-ip-dal.ts | 10 + .../services/trusted-ip/trusted-ip-service.ts | 150 +++++ .../services/trusted-ip/trusted-ip-types.ts | 17 + backend-pg/src/lib/config/env.ts | 5 +- backend-pg/src/lib/errors/index.ts | 12 + backend-pg/src/server/plugins/swagger.ts | 3 +- backend-pg/src/server/routes/index.ts | 57 +- .../src/server/routes/v1/identity-ua.ts | 6 +- backend-pg/src/server/routes/v1/index.ts | 20 +- .../server/routes/v1/organization-router.ts | 3 - .../server/routes/v2/organization-router.ts | 3 +- .../src/services/auth-token/auth-token-dal.ts | 72 ++- .../src/services/auth/auth-signup-service.ts | 20 +- .../identity-ua/identity-ua-service.ts | 30 +- .../src/services/identity/identity-service.ts | 2 +- .../integration-auth-service.ts | 1 - .../integration/integration-service.ts | 2 +- backend-pg/src/services/org/org-dal.ts | 7 +- backend-pg/src/services/org/org-service.ts | 48 +- .../project-env/project-env-service.ts | 23 +- .../project-membership-service.ts | 14 +- .../src/services/project/project-service.ts | 18 +- .../service-token/service-token-service.ts | 1 - .../src/services/webhook/webhook-service.ts | 1 - frontend/src/hooks/api/trustedIps/types.ts | 2 +- .../OrgMembersSection/OrgMembersTable.tsx | 2 +- 47 files changed, 1818 insertions(+), 121 deletions(-) create mode 100644 backend-pg/src/db/migrations/20240113103743_trusted-ip.ts create mode 100644 backend-pg/src/db/schemas/trusted-ips.ts create mode 100644 backend-pg/src/ee/routes/v1/license-router.ts create mode 100644 backend-pg/src/ee/routes/v1/trusted-ip-router.ts create mode 100644 backend-pg/src/ee/services/license/licence-fns.ts create mode 100644 backend-pg/src/ee/services/trusted-ip/trusted-ip-dal.ts create mode 100644 backend-pg/src/ee/services/trusted-ip/trusted-ip-service.ts create mode 100644 backend-pg/src/ee/services/trusted-ip/trusted-ip-types.ts diff --git a/backend-pg/package-lock.json b/backend-pg/package-lock.json index a8f7ea78e..6d4b06dcd 100644 --- a/backend-pg/package-lock.json +++ b/backend-pg/package-lock.json @@ -22,6 +22,7 @@ "@fastify/swagger-ui": "^1.10.1", "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", + "@octokit/webhooks-types": "^7.3.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", @@ -44,6 +45,7 @@ "lodash.isequal": "^4.5.0", "mysql2": "^3.6.5", "nanoid": "^5.0.4", + "node-cache": "^5.1.2", "nodemailer": "^6.9.7", "ora": "^7.0.1", "passport-github": "^1.1.0", @@ -60,7 +62,6 @@ "zod-to-json-schema": "^3.22.0" }, "devDependencies": { - "@octokit/webhooks-types": "^7.3.1", "@types/bcrypt": "^5.0.2", "@types/jmespath": "^0.15.2", "@types/jsonwebtoken": "^9.0.5", @@ -2313,8 +2314,7 @@ "node_modules/@octokit/webhooks-types": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.3.1.tgz", - "integrity": "sha512-u6355ZsZnHwmxen30SrqnYb1pXieBFkYgkNzt+Ed4Ao5tupN1OErHfzwiV6hq6duGkDAYASbq7/uVJQ69PjLEg==", - "dev": true + "integrity": "sha512-u6355ZsZnHwmxen30SrqnYb1pXieBFkYgkNzt+Ed4Ao5tupN1OErHfzwiV6hq6duGkDAYASbq7/uVJQ69PjLEg==" }, "node_modules/@octokit/webhooks/node_modules/@octokit/openapi-types": { "version": "12.11.0", @@ -5342,6 +5342,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -8929,6 +8937,17 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", diff --git a/backend-pg/package.json b/backend-pg/package.json index 73aa54cb8..4f4fe52ac 100644 --- a/backend-pg/package.json +++ b/backend-pg/package.json @@ -78,8 +78,8 @@ "@fastify/swagger-ui": "^1.10.1", "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", - "@ucast/mongo2js": "^1.3.4", "@octokit/webhooks-types": "^7.3.1", + "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", "aws-sdk": "^2.1532.0", @@ -101,6 +101,7 @@ "lodash.isequal": "^4.5.0", "mysql2": "^3.6.5", "nanoid": "^5.0.4", + "node-cache": "^5.1.2", "nodemailer": "^6.9.7", "ora": "^7.0.1", "passport-github": "^1.1.0", diff --git a/backend-pg/src/@types/fastify.d.ts b/backend-pg/src/@types/fastify.d.ts index 6557f5079..c2ccd5d97 100644 --- a/backend-pg/src/@types/fastify.d.ts +++ b/backend-pg/src/@types/fastify.d.ts @@ -3,6 +3,7 @@ import "fastify"; import { TUsers } from "@app/db/schemas"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -10,6 +11,7 @@ import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-ap import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; +import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { TAuthLoginFactory } from "@app/services/auth/auth-login-service"; @@ -101,6 +103,8 @@ declare module "fastify" { saml: TSamlConfigServiceFactory; auditLog: TAuditLogServiceFactory; secretScanning: TSecretScanningServiceFactory; + license: TLicenseServiceFactory; + trustedIp: TTrustedIpServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data diff --git a/backend-pg/src/@types/knex.d.ts b/backend-pg/src/@types/knex.d.ts index efa0a8dab..252708c68 100644 --- a/backend-pg/src/@types/knex.d.ts +++ b/backend-pg/src/@types/knex.d.ts @@ -148,6 +148,9 @@ import { TSuperAdmin, TSuperAdminInsert, TSuperAdminUpdate, + TTrustedIps, + TTrustedIpsInsert, + TTrustedIpsUpdate, TUserActions, TUserActionsInsert, TUserActionsUpdate, @@ -393,6 +396,11 @@ declare module "knex/types/tables" { TSecretScanningGitRisksInsert, TSecretScanningGitRisksUpdate >; + [TableName.TrustedIps]: Knex.CompositeTableType< + TTrustedIps, + TTrustedIpsInsert, + TTrustedIpsUpdate + >; // Junction tables [TableName.JnSecretTag]: Knex.CompositeTableType< TSecretTagJunction, diff --git a/backend-pg/src/db/migrations/20231212110939_project.ts b/backend-pg/src/db/migrations/20231212110939_project.ts index 7591b5b9b..732c80c10 100644 --- a/backend-pg/src/db/migrations/20231212110939_project.ts +++ b/backend-pg/src/db/migrations/20231212110939_project.ts @@ -6,7 +6,7 @@ import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; export async function up(knex: Knex): Promise { if (!(await knex.schema.hasTable(TableName.Project))) { await knex.schema.createTable(TableName.Project, (t) => { - t.string("id").primary().defaultTo(knex.fn.uuid()); + t.string("id", 36).primary().defaultTo(knex.fn.uuid()); t.string("name").notNullable(); t.boolean("autoCapitalization").defaultTo(true); t.uuid("orgId").notNullable(); diff --git a/backend-pg/src/db/migrations/20231225072545_service-token.ts b/backend-pg/src/db/migrations/20231225072545_service-token.ts index 9d385f245..0dd20af82 100644 --- a/backend-pg/src/db/migrations/20231225072545_service-token.ts +++ b/backend-pg/src/db/migrations/20231225072545_service-token.ts @@ -6,7 +6,7 @@ import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; export async function up(knex: Knex): Promise { if (!(await knex.schema.hasTable(TableName.ServiceToken))) { await knex.schema.createTable(TableName.ServiceToken, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("id", 36).primary().defaultTo(knex.fn.uuid()); t.string("name").notNullable(); t.jsonb("scopes").notNullable(); t.specificType("permissions", "text[]").notNullable(); diff --git a/backend-pg/src/db/migrations/20240113103743_trusted-ip.ts b/backend-pg/src/db/migrations/20240113103743_trusted-ip.ts new file mode 100644 index 000000000..2924f0668 --- /dev/null +++ b/backend-pg/src/db/migrations/20240113103743_trusted-ip.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.TrustedIps))) { + await knex.schema.createTable(TableName.TrustedIps, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("ipAddress").notNullable(); + t.string("type").notNullable(); + t.integer("prefix"); + t.boolean("isActive").defaultTo(true); + t.string("comment"); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.TrustedIps); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.TrustedIps); + await dropOnUpdateTrigger(knex, TableName.TrustedIps); +} diff --git a/backend-pg/src/db/schemas/index.ts b/backend-pg/src/db/schemas/index.ts index 8367831cd..b02cab80a 100644 --- a/backend-pg/src/db/schemas/index.ts +++ b/backend-pg/src/db/schemas/index.ts @@ -48,6 +48,7 @@ export * from "./secret-versions"; export * from "./secrets"; export * from "./service-tokens"; export * from "./super-admin"; +export * from "./trusted-ips"; export * from "./user-actions"; export * from "./user-encryption-keys"; export * from "./users"; diff --git a/backend-pg/src/db/schemas/models.ts b/backend-pg/src/db/schemas/models.ts index 7f8d323eb..0fab0665c 100644 --- a/backend-pg/src/db/schemas/models.ts +++ b/backend-pg/src/db/schemas/models.ts @@ -53,6 +53,7 @@ export enum TableName { GitAppInstallSession = "git_app_install_sessions", GitAppOrg = "git_app_org", SecretScanningGitRisk = "secret_scanning_git_risks", + TrustedIps = "trusted_ips", // junction tables JnSecretTag = "secret_tag_junction", JnSecretVersionTag = "secret_version_tag_junction" diff --git a/backend-pg/src/db/schemas/trusted-ips.ts b/backend-pg/src/db/schemas/trusted-ips.ts new file mode 100644 index 000000000..c3311340d --- /dev/null +++ b/backend-pg/src/db/schemas/trusted-ips.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const TrustedIpsSchema = z.object({ + id: z.string().uuid(), + ipAddress: z.string(), + type: z.string(), + prefix: z.number().nullable().optional(), + isActive: z.boolean().default(true).nullable().optional(), + comment: z.string().nullable().optional(), + projectId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TTrustedIps = z.infer; +export type TTrustedIpsInsert = Omit; +export type TTrustedIpsUpdate = Partial>; diff --git a/backend-pg/src/ee/routes/v1/index.ts b/backend-pg/src/ee/routes/v1/index.ts index c1ded6edf..41e5817b7 100644 --- a/backend-pg/src/ee/routes/v1/index.ts +++ b/backend-pg/src/ee/routes/v1/index.ts @@ -1,3 +1,4 @@ +import { registerLicenseRouter } from "./license-router"; import { registerOrgRoleRouter } from "./org-role-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; @@ -8,14 +9,17 @@ import { registerSecretRotationProviderRouter } from "./secret-rotation-provider import { registerSecretRotationRouter } from "./secret-rotation-router"; import { registerSecretScanningRouter } from "./secret-scanning-router"; import { registerSnapshotRouter } from "./snapshot-router"; +import { registerTrustedIpRouter } from "./trusted-ip-router"; export const registerV1EERoutes = async (server: FastifyZodProvider) => { // org role starts with organization await server.register(registerOrgRoleRouter, { prefix: "/organization" }); + await server.register(registerLicenseRouter, { prefix: "/organizations" }); await server.register( - async (projectServer) => { - projectServer.register(registerProjectRoleRouter); - projectServer.register(registerProjectRouter); + async (projectRouter) => { + await projectRouter.register(registerProjectRoleRouter); + await projectRouter.register(registerProjectRouter); + await projectRouter.register(registerTrustedIpRouter); }, { prefix: "/workspace" } ); diff --git a/backend-pg/src/ee/routes/v1/license-router.ts b/backend-pg/src/ee/routes/v1/license-router.ts new file mode 100644 index 000000000..c2b9f96d3 --- /dev/null +++ b/backend-pg/src/ee/routes/v1/license-router.ts @@ -0,0 +1,365 @@ +import { z } from "zod"; + +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerLicenseRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/:organizationId/plans/table", + method: "GET", + schema: { + querystring: z.object({ billingCycle: z.enum(["monthly", "yearly"]) }), + params: z.object({ organizationId: z.string().trim() }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.getOrgPlansTableByBillCycle({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId, + billingCycle: req.query.billingCycle + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/plan", + method: "GET", + schema: { + params: z.object({ organizationId: z.string().trim() }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.getOrgPlan({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/plans", + method: "GET", + schema: { + params: z.object({ organizationId: z.string().trim() }), + querystring: z.object({ workspaceId: z.string().trim().optional() }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.getOrgPlan({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/session/trial", + method: "POST", + schema: { + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ success_url: z.string().trim() }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.startOrgTrail({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId, + success_url: req.body.success_url + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/plan/billing", + method: "GET", + schema: { + params: z.object({ organizationId: z.string().trim() }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.getOrgBillingInfo({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/plan/table", + method: "GET", + schema: { + params: z.object({ organizationId: z.string().trim() }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.getOrgPlanTable({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/billing-details", + method: "GET", + schema: { + params: z.object({ organizationId: z.string().trim() }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.getOrgBillingDetails({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/billing-details", + method: "PATCH", + schema: { + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ + email: z.string().trim().email().optional(), + name: z.string().trim().optional() + }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.updateOrgBillingDetails({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId, + name: req.body.name, + email: req.body.email + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/billing-details/payment-methods", + method: "GET", + schema: { + params: z.object({ organizationId: z.string().trim() }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.getOrgPmtMethods({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/billing-details/payment-methods", + method: "POST", + schema: { + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ + success_url: z.string().trim(), + cancel_url: z.string().trim() + }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.addOrgPmtMethods({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId, + success_url: req.body.success_url, + cancel_url: req.body.cancel_url + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/billing-details/payment-methods/:pmtMethodId", + method: "DELETE", + schema: { + params: z.object({ + organizationId: z.string().trim(), + pmtMethodId: z.string().trim() + }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.delOrgPmtMethods({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId, + pmtMethodId: req.params.pmtMethodId + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/billing-details/tax-ids", + method: "GET", + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.getOrgTaxIds({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/billing-details/tax-ids", + method: "POST", + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + body: z.object({ + type: z.string().trim(), + value: z.string().trim() + }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.addOrgTaxId({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId, + type: req.body.type, + value: req.body.value + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/billing-details/tax-ids/:taxId", + method: "DELETE", + schema: { + params: z.object({ + organizationId: z.string().trim(), + taxId: z.string().trim() + }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.delOrgTaxId({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId, + taxId: req.params.taxId + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/invoices", + method: "GET", + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.getOrgTaxInvoices({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId + }); + return data; + } + }); + + server.route({ + url: "/:organizationId/licenses", + method: "GET", + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const data = await server.services.license.getOrgLicenses({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.params.organizationId + }); + return data; + } + }); +}; diff --git a/backend-pg/src/ee/routes/v1/trusted-ip-router.ts b/backend-pg/src/ee/routes/v1/trusted-ip-router.ts new file mode 100644 index 000000000..fd56a2cda --- /dev/null +++ b/backend-pg/src/ee/routes/v1/trusted-ip-router.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; + +import { TrustedIpsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/:workspaceId/trusted-ips", + method: "GET", + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: z.object({ + trustedIps: TrustedIpsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const trustedIps = await server.services.trustedIp.listIpsByProjectId({ + projectId: req.params.workspaceId, + actor: req.permission.type, + actorId: req.permission.id + }); + return { trustedIps }; + } + }); + + server.route({ + url: "/:workspaceId/trusted-ips", + method: "POST", + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + ipAddress: z.string().trim(), + comment: z.string().trim().default(""), + isActive: z.boolean() + }), + response: { + 200: z.object({ + trustedIp: TrustedIpsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { trustedIp, project } = await server.services.trustedIp.addProjectIp({ + projectId: req.params.workspaceId, + actor: req.permission.type, + actorId: req.permission.id, + ...req.body + }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: project.orgId, + projectId: project.id, + event: { + type: EventType.ADD_TRUSTED_IP, + metadata: { + trustedIpId: trustedIp.id.toString(), + ipAddress: trustedIp.ipAddress, + prefix: trustedIp.prefix as number + } + } + }); + return { trustedIp }; + } + }); + + server.route({ + url: "/:workspaceId/trusted-ips/:trustedIpId", + method: "PATCH", + schema: { + params: z.object({ + workspaceId: z.string().trim(), + trustedIpId: z.string().trim() + }), + body: z.object({ + ipAddress: z.string().trim(), + comment: z.string().trim().default("") + }), + response: { + 200: z.object({ + trustedIp: TrustedIpsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { trustedIp, project } = await server.services.trustedIp.updateProjectIp({ + projectId: req.params.workspaceId, + actor: req.permission.type, + actorId: req.permission.id, + trustedIpId: req.params.trustedIpId, + ...req.body + }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: project.orgId, + projectId: project.id, + event: { + type: EventType.UPDATE_TRUSTED_IP, + metadata: { + trustedIpId: trustedIp.id.toString(), + ipAddress: trustedIp.ipAddress, + prefix: trustedIp.prefix as number + } + } + }); + return { trustedIp }; + } + }); + + server.route({ + url: "/:workspaceId/trusted-ips/:trustedIpId", + method: "DELETE", + schema: { + params: z.object({ + workspaceId: z.string().trim(), + trustedIpId: z.string().trim() + }), + response: { + 200: z.object({ + trustedIp: TrustedIpsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { trustedIp, project } = await server.services.trustedIp.deleteProjectIp({ + projectId: req.params.workspaceId, + actor: req.permission.type, + actorId: req.permission.id, + trustedIpId: req.params.trustedIpId + }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: project.orgId, + projectId: project.id, + event: { + type: EventType.DELETE_TRUSTED_IP, + metadata: { + trustedIpId: trustedIp.id.toString(), + ipAddress: trustedIp.ipAddress, + prefix: trustedIp.prefix as number + } + } + }); + return { trustedIp }; + } + }); +}; diff --git a/backend-pg/src/ee/services/audit-log/audit-log-queue.ts b/backend-pg/src/ee/services/audit-log/audit-log-queue.ts index b110a5cd8..a101c5b6b 100644 --- a/backend-pg/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend-pg/src/ee/services/audit-log/audit-log-queue.ts @@ -1,18 +1,24 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TProjectDalFactory } from "@app/services/project/project-dal"; +import { TLicenseServiceFactory } from "../license/license-service"; import { TAuditLogDalFactory } from "./audit-log-dal"; import { TCreateAuditLogDTO } from "./audit-log-types"; type TAuditLogQueueServiceFactoryDep = { auditLogDal: TAuditLogDalFactory; queueService: TQueueServiceFactory; + projectDal: Pick; + licenseService: Pick; }; export type TAuditLogQueueServiceFactory = ReturnType; export const auditLogQueueServiceFactory = ({ auditLogDal, - queueService + queueService, + projectDal, + licenseService }: TAuditLogQueueServiceFactoryDep) => { const pushToLog = async (data: TCreateAuditLogDTO) => { await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { @@ -24,8 +30,19 @@ export const auditLogQueueServiceFactory = ({ }; queueService.start(QueueName.AuditLog, async (job) => { - const { actor, orgId, event, ipAddress, projectId, userAgent, userAgentType } = job.data; + const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; + let { orgId } = job.data; const MS_IN_DAY = 24 * 60 * 60 * 1000; + + if (!orgId) { + // it will never be undefined for both org and project id + // TODO(akhilmhdh): use caching here in dal to avoid db calls + const project = await projectDal.findById(projectId as string); + orgId = project.orgId; + } + + const plan = await licenseService.getPlan(orgId); + const ttl = plan.auditLogsRetentionDays * MS_IN_DAY; await auditLogDal.create({ actor: actor.type, actorMetadata: actor.metadata, @@ -34,7 +51,7 @@ export const auditLogQueueServiceFactory = ({ ipAddress, orgId, eventType: event.type, - expiresAt: new Date(Date.now() + 30 * MS_IN_DAY), + expiresAt: new Date(Date.now() + ttl), eventMetadata: event.metadata, userAgentType }); diff --git a/backend-pg/src/ee/services/license/licence-fns.ts b/backend-pg/src/ee/services/license/licence-fns.ts new file mode 100644 index 000000000..4855e1b47 --- /dev/null +++ b/backend-pg/src/ee/services/license/licence-fns.ts @@ -0,0 +1,97 @@ +import axios, { AxiosError } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; + +import { TFeatureSet } from "./license-types"; + +export const getDefaultOnPremFeatures = (): TFeatureSet => ({ + _id: null, + slug: null, + tier: -1, + workspaceLimit: null, + workspacesUsed: 0, + memberLimit: null, + membersUsed: 0, + environmentLimit: null, + environmentsUsed: 0, + secretVersioning: true, + pitRecovery: false, + ipAllowlisting: false, + rbac: false, + customRateLimits: false, + customAlerts: false, + auditLogs: false, + auditLogsRetentionDays: 0, + samlSSO: false, + status: null, + trial_end: null, + has_used_trial: true, + secretApproval: false, + secretRotation: true +}); + +export const setupLicenceRequestWithStore = ( + baseURL: string, + refreshUrl: string, + licenseKey: string +) => { + let token: string; + const licenceReq = axios.create({ + baseURL, + timeout: 15 * 1000, + signal: AbortSignal.timeout(15 * 1000) + }); + + const refreshLicence = async () => { + const appCfg = getConfig(); + const { + data: { token: authToken } + } = await request.post( + refreshUrl, + {}, + { + baseURL: appCfg.LICENSE_SERVER_URL, + headers: { + "X-API-KEY": licenseKey + } + } + ); + token = authToken; + return token; + }; + + licenceReq.interceptors.request.use( + (config) => { + if (token && config.headers) { + // eslint-disable-next-line no-param-reassign + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (err) => Promise.reject(err) + ); + + licenceReq.interceptors.response.use( + (response) => response, + async (err) => { + const originalRequest = err.config; + + // eslint-disable-next-line + if ((err as AxiosError)?.response?.status === 401 && !originalRequest._retry) { + // eslint-disable-next-line + originalRequest._retry = true; + + // refresh + await refreshLicence(); + + licenceReq.defaults.headers.common.Authorization = `Bearer ${token}`; + return licenceReq(originalRequest); + } + + return Promise.reject(err); + } + ); + + return { request: licenceReq, refreshLicence }; +}; diff --git a/backend-pg/src/ee/services/license/license-dal.ts b/backend-pg/src/ee/services/license/license-dal.ts index 07d478aa3..cdeaa053b 100644 --- a/backend-pg/src/ee/services/license/license-dal.ts +++ b/backend-pg/src/ee/services/license/license-dal.ts @@ -1,6 +1,27 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; export type TLicenseDalFactory = ReturnType; -export const licenseDalFactory = (db: TDbClient) => ({ }); +export const licenseDalFactory = (db: TDbClient) => { + const countOfOrgMembers = async (orgId: string | null, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.OrgMembership) + .where({ status: OrgMembershipStatus.Accepted }) + .andWhere((bd) => { + if (orgId) { + bd.where({ orgId }); + } + }) + .count(); + return doc?.[0].count; + } catch (error) { + throw new DatabaseError({ error, name: "Count of Org Members" }); + } + }; + + return { countOfOrgMembers }; +}; diff --git a/backend-pg/src/ee/services/license/license-service.ts b/backend-pg/src/ee/services/license/license-service.ts index e1362a9fa..26ce0fe46 100644 --- a/backend-pg/src/ee/services/license/license-service.ts +++ b/backend-pg/src/ee/services/license/license-service.ts @@ -1,34 +1,523 @@ -import axios from "axios"; +import { ForbiddenError } from "@casl/ability"; +import NodeCache from "node-cache"; import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { TOrgDalFactory } from "@app/services/org/org-dal"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { getDefaultOnPremFeatures, setupLicenceRequestWithStore } from "./licence-fns"; import { TLicenseDalFactory } from "./license-dal"; +import { + InstanceType, + TAddOrgPmtMethodDTO, + TAddOrgTaxIdDTO, + TDelOrgPmtMethodDTO, + TDelOrgTaxIdDTO, + TFeatureSet, + TGetOrgBillInfoDTO, + TGetOrgTaxIdDTO, + TOrgInvoiceDTO, + TOrgLicensesDTO, + TOrgPlanDTO, + TOrgPlansTableDTO, + TOrgPmtMethodsDTO, + TStartOrgTrailDTO, + TUpdateOrgBillingDetailsDTO +} from "./license-types"; type TLicenseServiceFactoryDep = { + orgDal: Pick; + permissionService: Pick; licenseDal: TLicenseDalFactory; }; export type TLicenseServiceFactory = ReturnType; -export const licenseServiceFactory = ({ licenseDal }: TLicenseServiceFactoryDep) => { +const LICENSE_SERVER_CLOUD_LOGIN = "/api/auth/v1/license-server-login"; +const LICENSE_SERVER_ON_PREM_LOGIN = "/api/auth/v1/licence-login"; + +const FEATURE_CACHE_KEY = (orgId: string, projectId?: string) => `${orgId}-${projectId || ""}`; +export const licenseServiceFactory = ({ + orgDal, + permissionService, + licenseDal +}: TLicenseServiceFactoryDep) => { + let isValidLicense = false; + let instanceType = InstanceType.OnPrem; + let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures(); + const featureStore = new NodeCache({ stdTTL: 60 }); + const appCfg = getConfig(); - const licenceApi = axios.create({ - baseURL: appCfg.LICENCE_SERVER_URL - }); + const licenseServerCloudApi = setupLicenceRequestWithStore( + appCfg.LICENSE_SERVER_URL || "", + LICENSE_SERVER_CLOUD_LOGIN, + appCfg.LICENSE_SERVER_KEY || "" + ); + + const licenseServerOnPremApi = setupLicenceRequestWithStore( + appCfg.LICENSE_SERVER_URL || "", + LICENSE_SERVER_ON_PREM_LOGIN, + appCfg.LICENSE_KEY || "" + ); + + const init = async () => { + try { + if (appCfg.LICENSE_SERVER_KEY) { + const token = await licenseServerCloudApi.refreshLicence(); + if (token) instanceType = InstanceType.Cloud; + logger.info(`Instance type: ${InstanceType.Cloud}`); + isValidLicense = true; + return; + } + if (appCfg.LICENSE_KEY) { + const token = await licenseServerOnPremApi.refreshLicence(); + if (token) { + const { + data: { currentPlan } + } = await licenseServerOnPremApi.request.get<{ currentPlan: TFeatureSet }>( + "/api/license/v1/plan" + ); + onPremFeatures = currentPlan; + instanceType = InstanceType.EnterpriseOnPrem; + logger.info(`Instance type: ${InstanceType.EnterpriseOnPrem}`); + isValidLicense = true; + } + } + } catch (error) { + logger.error(error); + } + }; + + const getPlan = async (orgId: string, projectId?: string) => { + try { + if (instanceType === InstanceType.Cloud) { + const cachedPlan = featureStore.get(FEATURE_CACHE_KEY(orgId, projectId)); + if (cachedPlan) return cachedPlan; + + const org = await orgDal.findOrgById(orgId); + if (!org) throw new BadRequestError({ message: "Org not found" }); + const { + data: { currentPlan } + } = await licenseServerCloudApi.request.get<{ currentPlan: TFeatureSet }>( + `/api/license-server/v1/customers/${org.customerId}/cloud-plan`, + { + params: { + workspaceId: projectId + } + } + ); + featureStore.set(FEATURE_CACHE_KEY(org.id, projectId), currentPlan); + return currentPlan; + } + } catch (error) { + logger.error(error); + return onPremFeatures; + } + return onPremFeatures; + }; + + const refreshPlan = async (orgId: string, projectId?: string) => { + if (instanceType === InstanceType.Cloud) { + featureStore.del(FEATURE_CACHE_KEY(orgId, projectId)); + await getPlan(orgId, projectId); + } + }; const generateOrgCustomerId = async (orgName: string, email: string) => { - const { - data: { customerId } - } = await licenceApi.post("/api/license-server/v1/customers", { email, name: orgName }); - return customerId; + if (instanceType === InstanceType.Cloud) { + const { + data: { customerId } + } = await licenseServerCloudApi.request.post( + "/api/license-server/v1/customers", + { + email, + name: orgName + }, + { timeout: 5000, signal: AbortSignal.timeout(5000) } + ); + return customerId; + } }; const removeOrgCustomer = async (customerId: string) => { - await licenceApi.delete(`/api/license-server/v1/customers/${customerId}`); + await licenseServerCloudApi.request.delete(`/api/license-server/v1/customers/${customerId}`); + }; + + const updateSubscriptionOrgMemberCount = async (orgId: string) => { + if (instanceType === InstanceType.Cloud) { + const org = await orgDal.findOrgById(orgId); + if (!org) throw new BadRequestError({ message: "Org not found" }); + + const count = await licenseDal.countOfOrgMembers(orgId); + if (org?.customerId) { + await licenseServerCloudApi.request.patch( + `/api/license-server/v1/customers/${org.customerId}/cloud-plan`, + { + quantity: count + } + ); + } + featureStore.del(orgId); + } else if (instanceType === InstanceType.EnterpriseOnPrem) { + const usedSeats = await licenseDal.countOfOrgMembers(null); + await licenseServerOnPremApi.request.patch(`/api/license/v1/license`, { usedSeats }); + } + await refreshPlan(orgId); + }; + + // below all are api calls + const getOrgPlansTableByBillCycle = async ({ + orgId, + actor, + actorId, + billingCycle + }: TOrgPlansTableDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + const { data } = await licenseServerCloudApi.request.get( + `/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` + ); + return data; + }; + + const getOrgPlan = async ({ orgId, actor, actorId, projectId }: TOrgPlanDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + const plan = await getPlan(orgId, projectId); + return plan; + }; + + const startOrgTrail = async ({ orgId, actorId, actor, success_url }: TStartOrgTrailDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Billing + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + + const { + data: { url } + } = await licenseServerCloudApi.request.post( + `/api/license-server/v1/customers/${organization.customerId}/session/trail`, + { success_url } + ); + featureStore.del(FEATURE_CACHE_KEY(orgId)); + return { url }; + }; + + const getOrgBillingInfo = async ({ orgId, actor, actorId }: TGetOrgBillInfoDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + const { data } = await licenseServerCloudApi.request.get( + `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing` + ); + return data; + }; + + // returns org current plan feature table + const getOrgPlanTable = async ({ orgId, actor, actorId }: TGetOrgBillInfoDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + const { data } = await licenseServerCloudApi.request.get( + `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table` + ); + return data; + }; + + const getOrgBillingDetails = async ({ orgId, actor, actorId }: TGetOrgBillInfoDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerCloudApi.request.get( + `/api/license-server/v1/customers/${organization.customerId}/billing-details` + ); + return data; + }; + + const updateOrgBillingDetails = async ({ + actorId, + actor, + orgId, + name, + email + }: TUpdateOrgBillingDetailsDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + const { data } = await licenseServerCloudApi.request.patch( + `/api/license-server/v1/customers/${organization.customerId}/billing-details`, + { + name, + email + } + ); + return data; + }; + + const getOrgPmtMethods = async ({ orgId, actor, actorId }: TOrgPmtMethodsDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + + const { + data: { pmtMethods } + } = await licenseServerCloudApi.request.get( + `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods` + ); + return pmtMethods; + }; + + const addOrgPmtMethods = async ({ + orgId, + actor, + actorId, + success_url, + cancel_url + }: TAddOrgPmtMethodDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + const { + data: { url } + } = await licenseServerCloudApi.request.post( + `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`, + { + success_url, + cancel_url + } + ); + return { url }; + }; + + const delOrgPmtMethods = async ({ actorId, actor, orgId, pmtMethodId }: TDelOrgPmtMethodDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerCloudApi.request.delete( + `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods/${pmtMethodId}` + ); + return data; + }; + + const getOrgTaxIds = async ({ orgId, actor, actorId }: TGetOrgTaxIdDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + const { + data: { tax_ids: taxIds } + } = await licenseServerCloudApi.request.get( + `/api/license-server/v1/customers/${organization.customerId}/billing-details/tax-ids` + ); + return taxIds; + }; + + const addOrgTaxId = async ({ actorId, actor, orgId, type, value }: TAddOrgTaxIdDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerCloudApi.request.post( + `/api/license-server/v1/customers/${organization.customerId}/billing-details/tax-ids`, + { + type, + value + } + ); + return data; + }; + + const delOrgTaxId = async ({ orgId, actor, actorId, taxId }: TDelOrgTaxIdDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerCloudApi.request.delete( + `/api/license-server/v1/customers/${organization.customerId}/billing-details/tax-ids/${taxId}` + ); + return data; + }; + + const getOrgTaxInvoices = async ({ actorId, actor, orgId }: TOrgInvoiceDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + + const { + data: { invoices } + } = await licenseServerCloudApi.request.get( + `/api/license-server/v1/customers/${organization.customerId}/invoices` + ); + return invoices; + }; + + const getOrgLicenses = async ({ orgId, actor, actorId }: TOrgLicensesDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await orgDal.findOrgById(orgId); + if (!organization) { + throw new BadRequestError({ + message: "Failed to find organization" + }); + } + + const { + data: { licenses } + } = await licenseServerCloudApi.request.get( + `/api/license-server/v1/customers/${organization.customerId}/licenses` + ); + return licenses; }; return { generateOrgCustomerId, - removeOrgCustomer + removeOrgCustomer, + init, + get isValidLicense() { + return isValidLicense; + }, + getPlan, + updateSubscriptionOrgMemberCount, + refreshPlan, + getOrgPlan, + getOrgPlansTableByBillCycle, + startOrgTrail, + getOrgBillingInfo, + getOrgPlanTable, + getOrgBillingDetails, + updateOrgBillingDetails, + addOrgPmtMethods, + delOrgPmtMethods, + getOrgPmtMethods, + getOrgLicenses, + getOrgTaxInvoices, + getOrgTaxIds, + addOrgTaxId, + delOrgTaxId }; }; diff --git a/backend-pg/src/ee/services/license/license-types.ts b/backend-pg/src/ee/services/license/license-types.ts index e69de29bb..f4cd2db1c 100644 --- a/backend-pg/src/ee/services/license/license-types.ts +++ b/backend-pg/src/ee/services/license/license-types.ts @@ -0,0 +1,72 @@ +import { TOrgPermission } from "@app/lib/types"; + +export enum InstanceType { + OnPrem = "self-hosted", + EnterpriseOnPrem = "enterprise-self-hosted", + Cloud = "cloud" +} + +export type TFeatureSet = { + _id: null; + slug: null; + tier: -1; + workspaceLimit: null; + workspacesUsed: 0; + memberLimit: null; + membersUsed: 0; + environmentLimit: null; + environmentsUsed: 0; + secretVersioning: true; + pitRecovery: false; + ipAllowlisting: false; + rbac: false; + customRateLimits: false; + customAlerts: false; + auditLogs: false; + auditLogsRetentionDays: 0; + samlSSO: false; + status: null; + trial_end: null; + has_used_trial: true; + secretApproval: false; + secretRotation: true; +}; + +export type TOrgPlansTableDTO = { + billingCycle: string; +} & TOrgPermission; + +export type TOrgPlanDTO = { + projectId?: string; +} & TOrgPermission; + +export type TStartOrgTrailDTO = { + success_url: string; +} & TOrgPermission; + +export type TGetOrgBillInfoDTO = TOrgPermission; + +export type TOrgPlanTableDTO = TOrgPermission; + +export type TOrgBillingDetailsDTO = TOrgPermission; + +export type TUpdateOrgBillingDetailsDTO = TOrgPermission & { + name?: string; + email?: string; +}; + +export type TOrgPmtMethodsDTO = TOrgPermission; + +export type TAddOrgPmtMethodDTO = TOrgPermission & { success_url: string; cancel_url: string }; + +export type TDelOrgPmtMethodDTO = TOrgPermission & { pmtMethodId: string }; + +export type TGetOrgTaxIdDTO = TOrgPermission; + +export type TAddOrgTaxIdDTO = TOrgPermission & { type: string; value: string }; + +export type TDelOrgTaxIdDTO = TOrgPermission & { taxId: string }; + +export type TOrgInvoiceDTO = TOrgPermission; + +export type TOrgLicensesDTO = TOrgPermission; diff --git a/backend-pg/src/ee/services/saml-config/saml-config-service.ts b/backend-pg/src/ee/services/saml-config/saml-config-service.ts index 0363f38fc..ee0efb9ac 100644 --- a/backend-pg/src/ee/services/saml-config/saml-config-service.ts +++ b/backend-pg/src/ee/services/saml-config/saml-config-service.ts @@ -20,6 +20,7 @@ import { TOrgBotDalFactory } from "@app/services/org/org-bot-dal"; import { TOrgDalFactory } from "@app/services/org/org-dal"; import { TUserDalFactory } from "@app/services/user/user-dal"; +import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { TSamlConfigDalFactory } from "./saml-config-dal"; @@ -40,6 +41,7 @@ type TSamlConfigServiceFactoryDep = { >; orgBotDal: Pick; permissionService: Pick; + licenseService: Pick; }; export type TSamlConfigServiceFactory = ReturnType; @@ -49,7 +51,8 @@ export const samlConfigServiceFactory = ({ orgBotDal, orgDal, userDal, - permissionService + permissionService, + licenseService }: TSamlConfigServiceFactoryDep) => { const createSamlCfg = async ({ cert, @@ -67,7 +70,12 @@ export const samlConfigServiceFactory = ({ OrgPermissionSubjects.Sso ); - // TODO(akhilmhdh-pg): licence check + const plan = await licenseService.getPlan(orgId); + if (!plan.samlSSO) + throw new BadRequestError({ + message: + "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + }); const orgBot = await orgBotDal.findOne({ orgId }); if (!orgBot) @@ -123,6 +131,13 @@ export const samlConfigServiceFactory = ({ OrgPermissionActions.Edit, OrgPermissionSubjects.Sso ); + const plan = await licenseService.getPlan(orgId); + if (!plan.samlSSO) + throw new BadRequestError({ + message: + "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + }); + const updateQuery: TSamlConfigsUpdate = { authProvider, isActive }; const orgBot = await orgBotDal.findOne({ orgId }); if (!orgBot) diff --git a/backend-pg/src/ee/services/secret-rotation/secret-rotation-service.ts b/backend-pg/src/ee/services/secret-rotation/secret-rotation-service.ts index 4a8f9bb01..0c2f16084 100644 --- a/backend-pg/src/ee/services/secret-rotation/secret-rotation-service.ts +++ b/backend-pg/src/ee/services/secret-rotation/secret-rotation-service.ts @@ -4,8 +4,10 @@ import Ajv from "ajv"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { TProjectPermission } from "@app/lib/types"; +import { TProjectDalFactory } from "@app/services/project/project-dal"; import { TProjectEnvDalFactory } from "@app/services/project-env/project-env-dal"; +import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; import { TSecretRotationDalFactory } from "./secret-rotation-dal"; @@ -22,6 +24,8 @@ import { rotationTemplates } from "./templates"; type TSecretRotationServiceFactoryDep = { secretRotationDal: TSecretRotationDalFactory; + projectDal: Pick; + licenseService: Pick; projectEnvDal: Pick; permissionService: Pick; secretRotationQueue: TSecretRotationQueueFactory; @@ -34,7 +38,9 @@ export const secretRotationServiceFactory = ({ secretRotationDal, permissionService, projectEnvDal, - secretRotationQueue + secretRotationQueue, + licenseService, + projectDal }: TSecretRotationServiceFactoryDep) => { const getProviderTemplates = async ({ actor, actorId, projectId }: TProjectPermission) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); @@ -68,6 +74,14 @@ export const secretRotationServiceFactory = ({ const env = await projectEnvDal.findOne({ slug: environment }); if (!env) throw new BadRequestError({ message: "Environment not found" }); + const project = await projectDal.findById(projectId); + const plan = await licenseService.getPlan(project.orgId); + if (!plan.secretRotation) + throw new BadRequestError({ + message: + "Failed to add secret rotation due to plan restriction. Upgrade plan to add secret rotation." + }); + const selectedTemplate = rotationTemplates.find(({ name }) => name === provider); if (!selectedTemplate) throw new BadRequestError({ message: "Provider not found" }); const formattedInputs: Record = {}; @@ -149,6 +163,14 @@ export const secretRotationServiceFactory = ({ const doc = await secretRotationDal.findById(rotationId); if (!doc) throw new BadRequestError({ message: "Rotation not found" }); + const project = await projectDal.findById(doc.projectId); + const plan = await licenseService.getPlan(project.orgId); + if (!plan.secretRotation) + throw new BadRequestError({ + message: + "Failed to add secret rotation due to plan restriction. Upgrade plan to add secret rotation." + }); + const { permission } = await permissionService.getProjectPermission( actor, actorId, diff --git a/backend-pg/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend-pg/src/ee/services/secret-snapshot/secret-snapshot-service.ts index 25929f3a4..4691c5e1c 100644 --- a/backend-pg/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend-pg/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -1,12 +1,13 @@ import { ForbiddenError } from "@casl/ability"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { TSecretDalFactory } from "@app/services/secret/secret-dal"; import { TSecretVersionDalFactory } from "@app/services/secret/secret-version-dal"; import { TSecretFolderDalFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretFolderVersionDalFactory } from "@app/services/secret-folder/secret-folder-version-dal"; +import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; import { @@ -34,6 +35,7 @@ type TSecretSnapshotServiceFactoryDep = { "findById" | "findBySecretPath" | "delete" | "insertMany" >; permissionService: Pick; + licenseService: Pick; }; export type TSecretSnapshotServiceFactory = ReturnType; @@ -46,7 +48,8 @@ export const secretSnapshotServiceFactory = ({ snapshotFolderDal, folderDal, secretDal, - permissionService + permissionService, + licenseService }: TSecretSnapshotServiceFactoryDep) => { const projectSecretSnapshotCount = async ({ environment, @@ -109,6 +112,9 @@ export const secretSnapshotServiceFactory = ({ }; const performSnapshot = async (folderId: string) => { + if (!licenseService.isValidLicense) + throw new InternalServerError({ message: "Invalid license" }); + const snapshot = await snapshotDal.transaction(async (tx) => { const folder = await folderDal.findById(folderId, tx); if (!folder) throw new BadRequestError({ message: "Folder not found" }); diff --git a/backend-pg/src/ee/services/trusted-ip/trusted-ip-dal.ts b/backend-pg/src/ee/services/trusted-ip/trusted-ip-dal.ts new file mode 100644 index 000000000..b40f21fa7 --- /dev/null +++ b/backend-pg/src/ee/services/trusted-ip/trusted-ip-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TTrustedIpDalFactory = ReturnType; + +export const trustedIpDalFactory = (db: TDbClient) => { + const trustedIpOrm = ormify(db, TableName.TrustedIps); + return trustedIpOrm; +}; diff --git a/backend-pg/src/ee/services/trusted-ip/trusted-ip-service.ts b/backend-pg/src/ee/services/trusted-ip/trusted-ip-service.ts new file mode 100644 index 000000000..c141398c8 --- /dev/null +++ b/backend-pg/src/ee/services/trusted-ip/trusted-ip-service.ts @@ -0,0 +1,150 @@ +import { ForbiddenError } from "@casl/ability"; + +import { BadRequestError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { TProjectPermission } from "@app/lib/types"; +import { TProjectDalFactory } from "@app/services/project/project-dal"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TTrustedIpDalFactory } from "./trusted-ip-dal"; +import { TCreateIpDTO, TDeleteIpDTO, TUpdateIpDTO } from "./trusted-ip-types"; + +type TTrustedIpServiceFactoryDep = { + trustedIpDal: TTrustedIpDalFactory; + permissionService: Pick; + licenseService: Pick; + projectDal: Pick; +}; + +export type TTrustedIpServiceFactory = ReturnType; + +export const trustedIpServiceFactory = ({ + trustedIpDal, + permissionService, + licenseService, + projectDal +}: TTrustedIpServiceFactoryDep) => { + const listIpsByProjectId = async ({ projectId, actor, actorId }: TProjectPermission) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.IpAllowList + ); + const trustedIps = await trustedIpDal.find({ + projectId + }); + return trustedIps; + }; + + const addProjectIp = async ({ + projectId, + actorId, + actor, + ipAddress: ip, + comment, + isActive + }: TCreateIpDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.IpAllowList + ); + + const project = await projectDal.findById(projectId); + const plan = await licenseService.getPlan(project.orgId); + if (!plan.ipAllowlisting) + throw new BadRequestError({ + message: + "Failed to add IP access range due to plan restriction. Upgrade plan to add IP access range." + }); + + const isValidIp = isValidIpOrCidr(ip); + if (!isValidIp) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + const { ipAddress, type, prefix } = extractIPDetails(ip); + const trustedIp = await trustedIpDal.create({ + projectId, + ipAddress, + type, + prefix, + isActive, + comment + }); + + return { trustedIp, project }; // for audit log + }; + + const updateProjectIp = async ({ + projectId, + actorId, + actor, + ipAddress: ip, + comment, + trustedIpId + }: TUpdateIpDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.IpAllowList + ); + + const project = await projectDal.findById(projectId); + const plan = await licenseService.getPlan(project.orgId); + if (!plan.ipAllowlisting) + throw new BadRequestError({ + message: + "Failed to add IP access range due to plan restriction. Upgrade plan to add IP access range." + }); + + const isValidIp = isValidIpOrCidr(ip); + if (!isValidIp) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + const { ipAddress, type, prefix } = extractIPDetails(ip); + const [trustedIp] = await trustedIpDal.update( + { projectId, id: trustedIpId }, + { + projectId, + ipAddress, + type, + prefix: prefix === undefined ? null : prefix, + comment + } + ); + + return { trustedIp, project }; // for audit log + }; + + const deleteProjectIp = async ({ projectId, actorId, actor, trustedIpId }: TDeleteIpDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.IpAllowList + ); + + const project = await projectDal.findById(projectId); + const plan = await licenseService.getPlan(project.orgId); + if (!plan.ipAllowlisting) + throw new BadRequestError({ + message: + "Failed to add IP access range due to plan restriction. Upgrade plan to add IP access range." + }); + + const [trustedIp] = await trustedIpDal.delete({ projectId, id: trustedIpId }); + + return { trustedIp, project }; // for audit log + }; + return { + listIpsByProjectId, + addProjectIp, + updateProjectIp, + deleteProjectIp + }; +}; diff --git a/backend-pg/src/ee/services/trusted-ip/trusted-ip-types.ts b/backend-pg/src/ee/services/trusted-ip/trusted-ip-types.ts new file mode 100644 index 000000000..abe066d65 --- /dev/null +++ b/backend-pg/src/ee/services/trusted-ip/trusted-ip-types.ts @@ -0,0 +1,17 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateIpDTO = TProjectPermission & { + comment: string; + isActive?: boolean; + ipAddress: string; +}; + +export type TUpdateIpDTO = TProjectPermission & { + trustedIpId: string; + ipAddress: string; + comment: string; +}; + +export type TDeleteIpDTO = TProjectPermission & { + trustedIpId: string; +}; diff --git a/backend-pg/src/lib/config/env.ts b/backend-pg/src/lib/config/env.ts index 2787cea69..88249f27d 100644 --- a/backend-pg/src/lib/config/env.ts +++ b/backend-pg/src/lib/config/env.ts @@ -83,8 +83,9 @@ const envSchema = z SECRET_SCANNING_GIT_APP_ID: zpStr(z.string().optional()), SECRET_SCANNING_PRIVATE_KEY: zpStr(z.string().optional()), // LICENCE - LICENCE_SERVER_URL: zpStr(z.string().optional()), - LICENCE_SERVER_KEY: zpStr(z.string().optional()) + LICENSE_SERVER_URL: zpStr(z.string().optional()), + LICENSE_SERVER_KEY: zpStr(z.string().optional()), + LICENSE_KEY: zpStr(z.string().optional()) }) .transform((data) => ({ ...data, diff --git a/backend-pg/src/lib/errors/index.ts b/backend-pg/src/lib/errors/index.ts index b78dc40a7..55c90d922 100644 --- a/backend-pg/src/lib/errors/index.ts +++ b/backend-pg/src/lib/errors/index.ts @@ -11,6 +11,18 @@ export class DatabaseError extends Error { } } +export class InternalServerError extends Error { + name: string; + + error: unknown; + + constructor({ name, error, message }: { message?: string; name?: string; error?: unknown }) { + super(message || "Something went wrong"); + this.name = name || "InternalServerError"; + this.error = error; + } +} + export class UnauthorizedError extends Error { name: string; diff --git a/backend-pg/src/server/plugins/swagger.ts b/backend-pg/src/server/plugins/swagger.ts index 40a354bba..49ed8bdc8 100644 --- a/backend-pg/src/server/plugins/swagger.ts +++ b/backend-pg/src/server/plugins/swagger.ts @@ -4,7 +4,6 @@ import fp from "fastify-plugin"; import { jsonSchemaTransform } from "./fastify-zod"; -// TODO(akhilmhdh-pg): change the localhost port later export const fastifySwagger = fp(async (fastify) => { await fastify.register(swagger, { transform: jsonSchemaTransform, @@ -16,7 +15,7 @@ export const fastifySwagger = fp(async (fastify) => { }, servers: [ { - url: "http://localhost:4000", + url: "http://localhost:8080", description: "Local server" }, { diff --git a/backend-pg/src/server/routes/index.ts b/backend-pg/src/server/routes/index.ts index f15f49863..4e1da0b8d 100644 --- a/backend-pg/src/server/routes/index.ts +++ b/backend-pg/src/server/routes/index.ts @@ -5,6 +5,8 @@ import { registerV1EERoutes } from "@app/ee/routes/v1"; import { auditLogDalFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { licenseDalFactory } from "@app/ee/services/license/license-dal"; +import { licenseServiceFactory } from "@app/ee/services/license/license-service"; import { permissionDalFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { samlConfigDalFactory } from "@app/ee/services/saml-config/saml-config-dal"; @@ -28,6 +30,8 @@ import { secretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/s import { snapshotDalFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { snapshotFolderDalFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal"; import { snapshotSecretDalFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal"; +import { trustedIpDalFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; +import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { getConfig } from "@app/lib/config/env"; import { TQueueServiceFactory } from "@app/queue"; import { apiKeyDalFactory } from "@app/services/api-key/api-key-dal"; @@ -150,6 +154,7 @@ export const registerRoutes = async ( const identityUaClientSecretDal = identityUaClientSecretDalFactory(db); const auditLogDal = auditLogDalFactory(db); + const trustedIpDal = trustedIpDalFactory(db); // ee db layer ops const permissionDal = permissionDalFactory(db); @@ -168,6 +173,7 @@ export const registerRoutes = async ( const gitAppInstallSessionDal = gitAppInstallSessionDalFactory(db); const gitAppOrgDal = gitAppDalFactory(db); const secretScanningDal = secretScanningDalFactory(db); + const licenseDal = licenseDalFactory(db); const permissionService = permissionServiceFactory({ permissionDal, @@ -175,7 +181,19 @@ export const registerRoutes = async ( projectRoleDal, serviceTokenDal }); - const auditLogQueue = auditLogQueueServiceFactory({ auditLogDal, queueService }); + const licenseService = licenseServiceFactory({ permissionService, orgDal, licenseDal }); + const trustedIpService = trustedIpServiceFactory({ + licenseService, + projectDal, + trustedIpDal, + permissionService + }); + const auditLogQueue = auditLogQueueServiceFactory({ + auditLogDal, + queueService, + projectDal, + licenseService + }); const auditLogService = auditLogServiceFactory({ auditLogDal, permissionService, auditLogQueue }); const sapService = secretApprovalPolicyServiceFactory({ projectMembershipDal, @@ -189,7 +207,8 @@ export const registerRoutes = async ( orgBotDal, orgDal, userDal, - samlConfigDal + samlConfigDal, + licenseService }); const tokenService = tokenServiceFactory({ tokenDal: authTokenDal, userDal }); @@ -202,6 +221,8 @@ export const registerRoutes = async ( userDal }); const orgService = orgServiceFactory({ + licenseService, + samlConfigDal, orgRoleDal, permissionService, orgDal, @@ -217,7 +238,8 @@ export const registerRoutes = async ( authDal, userDal, orgDal, - orgService + orgService, + licenseService }); const orgRoleService = orgRoleServiceFactory({ permissionService, orgRoleDal }); const superAdminService = superAdminServiceFactory({ @@ -247,7 +269,8 @@ export const registerRoutes = async ( secretBlindIndexDal, projectEnvDal, projectMembershipDal, - folderDal + folderDal, + licenseService }); const projectMembershipService = projectMembershipServiceFactory({ projectMembershipDal, @@ -257,9 +280,15 @@ export const registerRoutes = async ( userDal, smtpService, projectKeyDal, - projectRoleDal + projectRoleDal, + licenseService + }); + const projectEnvService = projectEnvServiceFactory({ + permissionService, + projectEnvDal, + licenseService, + projectDal }); - const projectEnvService = projectEnvServiceFactory({ permissionService, projectEnvDal }); const projectKeyService = projectKeyServiceFactory({ permissionService, projectKeyDal, @@ -275,7 +304,8 @@ export const registerRoutes = async ( snapshotSecretDal, secretVersionDal, folderVersionDal, - permissionService + permissionService, + licenseService }); const webhookService = webhookServiceFactory({ permissionService, @@ -350,7 +380,9 @@ export const registerRoutes = async ( permissionService, projectEnvDal, secretRotationDal, - secretRotationQueue + secretRotationQueue, + projectDal, + licenseService }); const integrationService = integrationServiceFactory({ @@ -383,10 +415,13 @@ export const registerRoutes = async ( identityDal, identityAccessTokenDal, identityUaClientSecretDal, - identityUaDal + identityUaDal, + licenseService }); await superAdminService.initServerCfg(); + // setup the communication with license key server + await licenseService.init(); // inject all services server.decorate("services", { login: loginService, @@ -423,7 +458,9 @@ export const registerRoutes = async ( snapshot: snapshotService, saml: samlService, auditLog: auditLogService, - secretScanning: secretScanningService + secretScanning: secretScanningService, + license: licenseService, + trustedIp: trustedIpService }); server.decorate("store", { diff --git a/backend-pg/src/server/routes/v1/identity-ua.ts b/backend-pg/src/server/routes/v1/identity-ua.ts index 662dd071a..09b086bac 100644 --- a/backend-pg/src/server/routes/v1/identity-ua.ts +++ b/backend-pg/src/server/routes/v1/identity-ua.ts @@ -39,7 +39,11 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { identityUa, accessToken, identityAccessToken, validClientSecretInfo } = - await server.services.identityUa.login(req.body.clientId, req.body.clientSecret); + await server.services.identityUa.login( + req.body.clientId, + req.body.clientSecret, + req.realIp + ); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend-pg/src/server/routes/v1/index.ts b/backend-pg/src/server/routes/v1/index.ts index 6a17935ff..744ba6ebf 100644 --- a/backend-pg/src/server/routes/v1/index.ts +++ b/backend-pg/src/server/routes/v1/index.ts @@ -24,10 +24,10 @@ import { registerWebhookRouter } from "./webhook-router"; export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerSsoRouter, { prefix: "/sso" }); await server.register( - async (authServer) => { - await authServer.register(registerAuthRoutes); - await authServer.register(registerIdentityUaRouter); - await authServer.register(registerIdentityAccessTokenRouter); + async (authRouter) => { + await authRouter.register(registerAuthRoutes); + await authRouter.register(registerIdentityUaRouter); + await authRouter.register(registerIdentityAccessTokenRouter); }, { prefix: "/auth" } ); @@ -41,12 +41,12 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerSecretFolderRouter, { prefix: "/folders" }); await server.register( - async (projectServer) => { - await projectServer.register(registerProjectRouter); - await projectServer.register(registerProjectEnvRouter); - await projectServer.register(registerProjectKeyRouter); - await projectServer.register(registerProjectMembershipRouter); - await projectServer.register(registerSecretTagRouter); + async (projectRouter) => { + await projectRouter.register(registerProjectRouter); + await projectRouter.register(registerProjectEnvRouter); + await projectRouter.register(registerProjectKeyRouter); + await projectRouter.register(registerProjectMembershipRouter); + await projectRouter.register(registerSecretTagRouter); }, { prefix: "/workspace" } ); diff --git a/backend-pg/src/server/routes/v1/organization-router.ts b/backend-pg/src/server/routes/v1/organization-router.ts index 3d4cd3dfa..f5fd06c87 100644 --- a/backend-pg/src/server/routes/v1/organization-router.ts +++ b/backend-pg/src/server/routes/v1/organization-router.ts @@ -84,8 +84,6 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); - // TODO(akhilmhdh-pg): missing my-workspace list - server.route({ method: "PATCH", url: "/:organizationId/name", @@ -161,7 +159,6 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { method: "DELETE", url: "/:organizationId/incidentContactOrg/:incidentContactId", schema: { - // TODO(akhilmhdh-pg): change accept id instead of email params: z.object({ organizationId: z.string().trim(), incidentContactId: z.string().trim() }), response: { 200: z.object({ diff --git a/backend-pg/src/server/routes/v2/organization-router.ts b/backend-pg/src/server/routes/v2/organization-router.ts index 88a723920..ed4a894c5 100644 --- a/backend-pg/src/server/routes/v2/organization-router.ts +++ b/backend-pg/src/server/routes/v2/organization-router.ts @@ -88,7 +88,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; - + const membership = await server.services.org.deleteOrgMembership({ userId: req.permission.id, orgId: req.params.organizationId, @@ -117,6 +117,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { const organization = await server.services.org.createOrganization( req.permission.id, + req.auth.user.email, req.body.name ); return { organization }; diff --git a/backend-pg/src/services/auth-token/auth-token-dal.ts b/backend-pg/src/services/auth-token/auth-token-dal.ts index cda3fa953..22c7aa7c0 100644 --- a/backend-pg/src/services/auth-token/auth-token-dal.ts +++ b/backend-pg/src/services/auth-token/auth-token-dal.ts @@ -11,27 +11,45 @@ export type TTokenDalConfig = {}; export type TTokenDalFactory = ReturnType; -// TODO(akhilmhdh-pg): wrap all with database error export const tokenDalFactory = (db: TDbClient) => { const authOrm = ormify(db, TableName.AuthTokens); const findOneTokenSession = async ( filter: Partial - ): Promise => - db(TableName.AuthTokenSession).where(filter).first(); + ): Promise => { + try { + const doc = await db(TableName.AuthTokenSession).where(filter).first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindOneTokenSession" }); + } + }; const deleteTokenForUser = async ({ userId, type, orgId - }: TDeleteTokenForUserDalDTO): Promise => - db(TableName.AuthTokens).where({ userId, type, orgId }).delete().returning("*"); + }: TDeleteTokenForUserDalDTO): Promise => { + try { + const doc = await db(TableName.AuthTokens) + .where({ userId, type, orgId }) + .delete() + .returning("*"); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "DeleteTokenForUser" }); + } + }; const decrementTriesField = async ({ userId, type }: TDeleteTokenForUserDalDTO): Promise => { - await db(TableName.AuthTokens).where({ userId, type }).decrement("triesLeft", 1); + try { + await db(TableName.AuthTokens).where({ userId, type }).decrement("triesLeft", 1); + } catch (error) { + throw new DatabaseError({ error, name: "DecrementTriesField" }); + } }; const findTokenSessions = async (filter: Partial, tx?: Knex) => { @@ -48,29 +66,37 @@ export const tokenDalFactory = (db: TDbClient) => { ip: string, userAgent: string ): Promise => { - const [session] = await db(TableName.AuthTokenSession) - .insert({ - userId, - ip, - userAgent, - accessVersion: 1, - refreshVersion: 1, - lastUsed: new Date() - }) - .returning("*"); - return session; + try { + const [session] = await db(TableName.AuthTokenSession) + .insert({ + userId, + ip, + userAgent, + accessVersion: 1, + refreshVersion: 1, + lastUsed: new Date() + }) + .returning("*"); + return session; + } catch (error) { + throw new DatabaseError({ error, name: "InsertTokenSession" }); + } }; const incrementTokenSessionVersion = async ( userId: string, sessionId: string ): Promise => { - const [session] = await db(TableName.AuthTokenSession) - .where({ userId, id: sessionId }) - .increment("accessVersion", 1) - .increment("refreshVersion", 1) - .returning("*"); - return session; + try { + const [session] = await db(TableName.AuthTokenSession) + .where({ userId, id: sessionId }) + .increment("accessVersion", 1) + .increment("refreshVersion", 1) + .returning("*"); + return session; + } catch (error) { + throw new DatabaseError({ error, name: "IncrementTokenSessionVersion" }); + } }; const deleteTokenSession = async (filter: Partial, tx?: Knex) => { diff --git a/backend-pg/src/services/auth/auth-signup-service.ts b/backend-pg/src/services/auth/auth-signup-service.ts index 3806fd091..7a2265d08 100644 --- a/backend-pg/src/services/auth/auth-signup-service.ts +++ b/backend-pg/src/services/auth/auth-signup-service.ts @@ -1,6 +1,7 @@ import jwt from "jsonwebtoken"; import { OrgMembershipStatus } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { isDisposableEmail } from "@app/lib/validator"; @@ -22,6 +23,7 @@ type TAuthSignupDep = { orgDal: TOrgDalFactory; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; + licenseService: Pick; }; export type TAuthSignupFactory = ReturnType; @@ -31,7 +33,8 @@ export const authSignupServiceFactory = ({ tokenService, smtpService, orgService, - orgDal + orgDal, + licenseService }: TAuthSignupDep) => { // first step of signup. create user and send email const beginEmailSignupProcess = async (email: string) => { @@ -143,13 +146,17 @@ export const authSignupServiceFactory = ({ ); if (!hasSamlEnabled) { - await orgService.createOrganization(user.id, organizationName); + await orgService.createOrganization(user.id, user.email, organizationName); } - await orgDal.updateMembership( + const updatedMembersips = await orgDal.updateMembership( { inviteEmail: email, status: OrgMembershipStatus.Invited }, { userId: user.id, status: OrgMembershipStatus.Accepted } ); + const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; + await Promise.allSettled( + uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId)) + ); const tokenSession = await tokenService.getUserTokenSession({ userAgent, @@ -238,11 +245,16 @@ export const authSignupServiceFactory = ({ tx ); - await orgDal.updateMembership( + const updatedMembersips = await orgDal.updateMembership( { inviteEmail: email, status: OrgMembershipStatus.Invited }, { userId: us.id, status: OrgMembershipStatus.Accepted }, tx ); + const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; + await Promise.allSettled( + uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId)) + ); + return { info: us, key: userEncKey }; }); diff --git a/backend-pg/src/services/identity-ua/identity-ua-service.ts b/backend-pg/src/services/identity-ua/identity-ua-service.ts index fa878a12a..e311a5b6a 100644 --- a/backend-pg/src/services/identity-ua/identity-ua-service.ts +++ b/backend-pg/src/services/identity-ua/identity-ua-service.ts @@ -5,6 +5,7 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects @@ -13,7 +14,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; -import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr,TIp } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDalFactory } from "../identity/identity-dal"; @@ -38,6 +39,7 @@ type TIdentityUaServiceFactoryDep = { identityOrgMembershipDal: TIdentityOrgDalFactory; identityDal: Pick; permissionService: Pick; + licenseService: Pick; }; export type TIdentityUaServiceFactory = ReturnType; @@ -48,13 +50,17 @@ export const identityUaServiceFactory = ({ identityAccessTokenDal, identityOrgMembershipDal, identityDal, - permissionService + permissionService, + licenseService }: TIdentityUaServiceFactoryDep) => { - const login = async (clientId: string, clientSecret: string) => { + const login = async (clientId: string, clientSecret: string, ip: string) => { const identityUa = await identityUaDal.findOne({ clientId }); if (!identityUa) throw new UnauthorizedError(); - // TODO(akhilmhdh-pg): add ip checking + checkIPAgainstBlocklist({ + ipAddress: ip, + trustedIps: identityUa.clientSecretTrustedIps as TIp[] + }); const clientSecrtInfo = await identityUaClientSecretDal.find({ identityUAId: identityUa.id, isClientSecretRevoked: false @@ -166,10 +172,11 @@ export const identityUaServiceFactory = ({ OrgPermissionActions.Create, OrgPermissionSubjects.Identity ); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map( (clientSecretTrustedIp) => { - // TODO(akhilmhdh-pg): add licence server here - if (/* !plan.ipAllowlisting && */ clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") + if (!plan.ipAllowlisting && clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") throw new BadRequestError({ message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." @@ -182,8 +189,7 @@ export const identityUaServiceFactory = ({ } ); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { - // TODO(akhilmhdh-pg): add licence server here - if (/* !plan.ipAllowlisting && */ accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") + if (!plan.ipAllowlisting && accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") throw new BadRequestError({ message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." @@ -256,10 +262,11 @@ export const identityUaServiceFactory = ({ OrgPermissionActions.Edit, OrgPermissionSubjects.Identity ); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedClientSecretTrustedIps = clientSecretTrustedIps?.map( (clientSecretTrustedIp) => { - // TODO(akhilmhdh-pg): add licence server here - if (/* !plan.ipAllowlisting && */ clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") + if (!plan.ipAllowlisting && clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") throw new BadRequestError({ message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." @@ -272,8 +279,7 @@ export const identityUaServiceFactory = ({ } ); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { - // TODO(akhilmhdh-pg): add licence server here - if (/* !plan.ipAllowlisting && */ accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") + if (!plan.ipAllowlisting && accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") throw new BadRequestError({ message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." diff --git a/backend-pg/src/services/identity/identity-service.ts b/backend-pg/src/services/identity/identity-service.ts index ff54ffb78..77d8791cc 100644 --- a/backend-pg/src/services/identity/identity-service.ts +++ b/backend-pg/src/services/identity/identity-service.ts @@ -56,7 +56,7 @@ export const identityServiceFactory = ({ return newIdentity; }); - // TODO(akhilmhdh-pg): add audit log here + return identity; }; diff --git a/backend-pg/src/services/integration-auth/integration-auth-service.ts b/backend-pg/src/services/integration-auth/integration-auth-service.ts index 3c0fab359..d9e9205c1 100644 --- a/backend-pg/src/services/integration-auth/integration-auth-service.ts +++ b/backend-pg/src/services/integration-auth/integration-auth-service.ts @@ -994,7 +994,6 @@ export const integrationAuthServiceFactory = ({ }); return delIntegrationAuth; - // TODO(akhilmhdh-pg): add audit log }; return { diff --git a/backend-pg/src/services/integration/integration-service.ts b/backend-pg/src/services/integration/integration-service.ts index ad013ecc0..2fe117142 100644 --- a/backend-pg/src/services/integration/integration-service.ts +++ b/backend-pg/src/services/integration/integration-service.ts @@ -90,7 +90,7 @@ export const integrationServiceFactory = ({ integration: integrationAuth.integration }); - // TODO(akhilmhdh-pg): audit log + return { integration, integrationAuth }; }; diff --git a/backend-pg/src/services/org/org-dal.ts b/backend-pg/src/services/org/org-dal.ts index eb32fcd5b..8f916e48b 100644 --- a/backend-pg/src/services/org/org-dal.ts +++ b/backend-pg/src/services/org/org-dal.ts @@ -4,6 +4,7 @@ import { TDbClient } from "@app/db"; import { TableName, TOrganizations, + TOrganizationsInsert, TOrgMemberships, TOrgMembershipsInsert, TOrgMembershipsUpdate @@ -73,11 +74,9 @@ export const orgDalFactory = (db: TDbClient) => { } }; - const create = async ({ name }: { name: string }, tx?: Knex) => { + const create = async (dto: TOrganizationsInsert, tx?: Knex) => { try { - const [organization] = await (tx || db)(TableName.Organization) - .insert({ name }) - .returning("*"); + const [organization] = await (tx || db)(TableName.Organization).insert(dto).returning("*"); return organization; } catch (error) { throw new DatabaseError({ error, name: "Create organization" }); diff --git a/backend-pg/src/services/org/org-service.ts b/backend-pg/src/services/org/org-service.ts index afa9f55a5..d0a72fc51 100644 --- a/backend-pg/src/services/org/org-service.ts +++ b/backend-pg/src/services/org/org-service.ts @@ -2,11 +2,13 @@ import { ForbiddenError } from "@casl/ability"; import jwt from "jsonwebtoken"; import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TSamlConfigDalFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; @@ -35,9 +37,14 @@ type TOrgServiceFactoryDep = { orgRoleDal: TOrgRoleDalFactory; userDal: TUserDalFactory; incidentContactDal: TIncidentContactsDalFactory; + samlConfigDal: Pick; smtpService: TSmtpService; tokenService: TAuthTokenServiceFactory; permissionService: TPermissionServiceFactory; + licenseService: Pick< + TLicenseServiceFactory, + "getPlan" | "updateSubscriptionOrgMemberCount" | "generateOrgCustomerId" | "removeOrgCustomer" + >; }; export type TOrgServiceFactory = ReturnType; @@ -50,7 +57,9 @@ export const orgServiceFactory = ({ permissionService, smtpService, tokenService, - orgBotDal + orgBotDal, + licenseService, + samlConfigDal }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -99,7 +108,7 @@ export const orgServiceFactory = ({ /* * Create organization * */ - const createOrganization = async (userId: string, orgName: string) => { + const createOrganization = async (userId: string, userEmail: string, orgName: string) => { const { privateKey, publicKey } = generateAsymmetricKeyPair(); const key = generateSymmetricKey(); const { @@ -117,8 +126,9 @@ export const orgServiceFactory = ({ algorithm: symmetricKeyAlgorithm } = infisicalSymmetricEncypt(key); + const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail); const organization = await orgDal.transaction(async (tx) => { - const org = await orgDal.create({ name: orgName }, tx); + const org = await orgDal.create({ name: orgName, customerId }, tx); await orgDal.createMembership( { userId, @@ -161,6 +171,9 @@ export const orgServiceFactory = ({ throw new UnauthorizedError({ name: "Delete org by id", message: "Not an admin" }); const organization = await orgDal.deleteById(orgId); + if (organization.customerId) { + await licenseService.removeOrgCustomer(organization.customerId); + } return organization; }; /* @@ -184,6 +197,14 @@ export const orgServiceFactory = ({ const customRole = await orgRoleDal.findOne({ slug: role, orgId }); if (!customRole) throw new BadRequestError({ name: "Update membership", message: "Role not found" }); + + const plan = await licenseService.getPlan(orgId); + if (!plan?.rbac) + throw new BadRequestError({ + message: + "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." + }); + const [membership] = await orgDal.updateMembership( { id: membershipId, orgId }, { @@ -210,7 +231,21 @@ export const orgServiceFactory = ({ OrgPermissionSubjects.Member ); - // TODO(akhilmhdh-pg): SAML SSO check and licence check limit org members + const samlCfg = await samlConfigDal.findOne({ orgId }); + if (samlCfg && samlCfg.isActive) { + throw new BadRequestError({ + message: "Failed to invite member due to SAML SSO configured for organization" + }); + } + const plan = await licenseService.getPlan(orgId); + if (plan.memberLimit !== null && plan.membersUsed >= plan.memberLimit) { + // case: limit imposed on number of members allowed + // case: number of members used exceeds the number of members allowed + throw new BadRequestError({ + message: + "Failed to invite member due to member limit reached. Upgrade plan to invite more members." + }); + } const invitee = await orgDal.transaction(async (tx) => { const inviteeUser = await userDal.findUserByEmail(inviteeEmail, tx); if (inviteeUser) { @@ -284,6 +319,7 @@ export const orgServiceFactory = ({ } }); + await licenseService.updateSubscriptionOrgMemberCount(orgId); if (!appCfg.isSmtpConfigured) { return `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${inviteeEmail}&organization_id=${org?.id}`; } @@ -323,7 +359,7 @@ export const orgServiceFactory = ({ orgId, status: OrgMembershipStatus.Accepted }); - // TODO(akhilmhdh-pg): update org licence subscription + await licenseService.updateSubscriptionOrgMemberCount(orgId); return { user }; } @@ -350,6 +386,8 @@ export const orgServiceFactory = ({ ); const membership = await orgDal.deleteMembershipById(membershipId, orgId); + + await licenseService.updateSubscriptionOrgMemberCount(orgId); return membership; }; diff --git a/backend-pg/src/services/project-env/project-env-service.ts b/backend-pg/src/services/project-env/project-env-service.ts index 0e64ca48b..b44ebc8d5 100644 --- a/backend-pg/src/services/project-env/project-env-service.ts +++ b/backend-pg/src/services/project-env/project-env-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, @@ -7,19 +8,24 @@ import { } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; +import { TProjectDalFactory } from "../project/project-dal"; import { TProjectEnvDalFactory } from "./project-env-dal"; import { TCreateEnvDTO, TDeleteEnvDTO, TUpdateEnvDTO } from "./project-env-types"; type TProjectEnvServiceFactoryDep = { projectEnvDal: TProjectEnvDalFactory; + projectDal: Pick; permissionService: Pick; + licenseService: Pick; }; export type TProjectEnvServiceFactory = ReturnType; export const projectEnvServiceFactory = ({ projectEnvDal, - permissionService + permissionService, + licenseService, + projectDal }: TProjectEnvServiceFactoryDep) => { const createEnvironment = async ({ projectId, actorId, actor, name, slug }: TCreateEnvDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); @@ -28,14 +34,25 @@ export const projectEnvServiceFactory = ({ ProjectPermissionSub.Environments ); - // TODO(akhilmhdh-pg): add licence service here - const existingEnv = await projectEnvDal.findOne({ slug }); + const envs = await projectEnvDal.find({ projectId }); + const existingEnv = envs.find(({ slug: envSlug }) => envSlug === slug); if (existingEnv) throw new BadRequestError({ message: "Environment with slug already exist", name: "Create envv" }); + const project = await projectDal.findById(projectId); + const plan = await licenseService.getPlan(project.orgId); + if (plan.environmentLimit !== null && envs.length >= plan.environmentLimit) { + // case: limit imposed on number of environments allowed + // case: number of environments used exceeds the number of environments allowed + throw new BadRequestError({ + message: + "Failed to create environment due to environment limit reached. Upgrade plan to create more environments." + }); + } + const env = await projectEnvDal.transaction(async (tx) => { const lastPos = await projectEnvDal.findLastEnvPosition(projectId, tx); const doc = await projectEnvDal.create({ slug, name, projectId, position: lastPos + 1 }, tx); diff --git a/backend-pg/src/services/project-membership/project-membership-service.ts b/backend-pg/src/services/project-membership/project-membership-service.ts index 04b71e18a..86066c7bb 100644 --- a/backend-pg/src/services/project-membership/project-membership-service.ts +++ b/backend-pg/src/services/project-membership/project-membership-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { OrgMembershipStatus, ProjectMembershipRole } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, @@ -34,6 +35,7 @@ type TProjectMembershipServiceFactoryDep = { orgDal: Pick; projectDal: Pick; projectKeyDal: Pick; + licenseService: Pick; }; export type TProjectMembershipServiceFactory = ReturnType; @@ -46,7 +48,8 @@ export const projectMembershipServiceFactory = ({ orgDal, userDal, projectDal, - projectKeyDal + projectKeyDal, + licenseService }: TProjectMembershipServiceFactoryDep) => { const getProjectMemberships = async ({ actorId, actor, projectId }: TGetProjectMembershipDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); @@ -120,7 +123,6 @@ export const projectMembershipServiceFactory = ({ } }); - // TODO(akhilmhdh-pg): Audit log return { invitee, latestKey }; }; @@ -209,6 +211,14 @@ export const projectMembershipServiceFactory = ({ const customRole = await projectRoleDal.findOne({ slug: role, projectId }); if (!customRole) throw new BadRequestError({ name: "Update project membership", message: "Role not found" }); + const project = await projectDal.findById(customRole.projectId); + const plan = await licenseService.getPlan(project.orgId); + if (!plan?.rbac) + throw new BadRequestError({ + message: + "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." + }); + const [membership] = await projectMembershipDal.update( { id: membershipId, projectId }, { diff --git a/backend-pg/src/services/project/project-service.ts b/backend-pg/src/services/project/project-service.ts index 886612ecb..3bb947a84 100644 --- a/backend-pg/src/services/project/project-service.ts +++ b/backend-pg/src/services/project/project-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { ProjectMembershipRole } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects @@ -12,6 +13,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { createSecretBlindIndex } from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; import { TProjectEnvDalFactory } from "../project-env/project-env-dal"; import { TProjectMembershipDalFactory } from "../project-membership/project-membership-dal"; @@ -33,6 +35,7 @@ type TProjectServiceFactoryDep = { projectMembershipDal: Pick; secretBlindIndexDal: Pick; permissionService: TPermissionServiceFactory; + licenseService: Pick; }; export type TProjectServiceFactory = ReturnType; @@ -43,7 +46,8 @@ export const projectServiceFactory = ({ folderDal, secretBlindIndexDal, projectMembershipDal, - projectEnvDal + projectEnvDal, + licenseService }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -57,7 +61,17 @@ export const projectServiceFactory = ({ const appCfg = getConfig(); const blindIndex = createSecretBlindIndex(appCfg.ROOT_ENCRYPTION_KEY, appCfg.ENCRYPTION_KEY); - // TODO(backend-pg): licence server + + const plan = await licenseService.getPlan(orgId); + if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) { + // case: limit imposed on number of workspaces allowed + // case: number of workspaces used exceeds the number of workspaces allowed + throw new BadRequestError({ + message: + "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces." + }); + } + const newProject = projectDal.transaction(async (tx) => { const project = await projectDal.create({ name: workspaceName, orgId }, tx); // set user as admin member for proeject diff --git a/backend-pg/src/services/service-token/service-token-service.ts b/backend-pg/src/services/service-token/service-token-service.ts index fd5aeb0bd..3d4c74ce0 100644 --- a/backend-pg/src/services/service-token/service-token-service.ts +++ b/backend-pg/src/services/service-token/service-token-service.ts @@ -82,7 +82,6 @@ export const serviceTokenServiceFactory = ({ }); const token = `st.${serviceToken.id.toString()}.${secret}`; - // TODO(akhilmhdh-pg): audit log return { token, serviceToken }; }; diff --git a/backend-pg/src/services/webhook/webhook-service.ts b/backend-pg/src/services/webhook/webhook-service.ts index 81c8ca127..2d7dea841 100644 --- a/backend-pg/src/services/webhook/webhook-service.ts +++ b/backend-pg/src/services/webhook/webhook-service.ts @@ -84,7 +84,6 @@ export const webhookServiceFactory = ({ } const webhook = await webhookDal.create(insertDoc); - // TODO(akhilmhdh-pg): add audit log return { ...webhook, projectId, environment: env }; }; diff --git a/frontend/src/hooks/api/trustedIps/types.ts b/frontend/src/hooks/api/trustedIps/types.ts index bd14b1c6e..00349382f 100644 --- a/frontend/src/hooks/api/trustedIps/types.ts +++ b/frontend/src/hooks/api/trustedIps/types.ts @@ -1,6 +1,6 @@ export type TrustedIp = { id: string; - workspace: string; + projectId: string; ipAddress: string; type: "ipv4" | "ipv6"; isActive: boolean; diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index fc979127e..5469bbf05 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -172,7 +172,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop {!isLoading && filterdUser?.map( ({ user: u, inviteEmail, role, roleId, id: orgMembershipId, status }) => { - const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-"; const email = u?.email || inviteEmail; return (