From afee158b95cadbc9a4f28394e586d9a908585f22 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 19 Jun 2024 10:31:58 -0700 Subject: [PATCH 1/3] Start adding identity based pricing logic --- .../services/license/__mocks__/licence-fns.ts | 2 ++ .../src/ee/services/license/licence-fns.ts | 2 ++ .../ee/services/license/license-service.ts | 1 + .../src/ee/services/license/license-types.ts | 2 ++ backend/src/server/routes/index.ts | 3 +- .../src/services/identity/identity-service.ts | 2 ++ backend/src/services/org/org-service.ts | 13 ++++++-- docker-compose.dev.yml | 2 +- frontend/src/hooks/api/subscriptions/types.ts | 2 ++ frontend/src/layouts/AppLayout/AppLayout.tsx | 26 ++++++++-------- .../IdentitySection/IdentitySection.tsx | 30 ++++++++++++++++--- .../OrgMembersSection/OrgMembersSection.tsx | 18 ++++++----- 12 files changed, 74 insertions(+), 29 deletions(-) diff --git a/backend/src/ee/services/license/__mocks__/licence-fns.ts b/backend/src/ee/services/license/__mocks__/licence-fns.ts index ddbffba457..a8b3b351dc 100644 --- a/backend/src/ee/services/license/__mocks__/licence-fns.ts +++ b/backend/src/ee/services/license/__mocks__/licence-fns.ts @@ -7,6 +7,8 @@ export const getDefaultOnPremFeatures = () => { workspacesUsed: 0, memberLimit: null, membersUsed: 0, + identityLimit: null, + identitiesUsed: 0, environmentLimit: null, environmentsUsed: 0, secretVersioning: true, diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 9d2c5a4724..45325d414d 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -15,6 +15,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ membersUsed: 0, environmentLimit: null, environmentsUsed: 0, + identityLimit: null, + identitiesUsed: 0, dynamicSecret: false, secretVersioning: true, pitRecovery: false, diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 46931468fd..0819c461b1 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -155,6 +155,7 @@ export const licenseServiceFactory = ({ LICENSE_SERVER_CLOUD_PLAN_TTL, JSON.stringify(currentPlan) ); + return currentPlan; } } catch (error) { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index e23ff2c841..565387296c 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -31,6 +31,8 @@ export type TFeatureSet = { dynamicSecret: false; memberLimit: null; membersUsed: 0; + identityLimit: null; + identitiesUsed: 0; environmentLimit: null; environmentsUsed: 0; secretVersioning: true; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 326fbafa61..f20a622aba 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -801,7 +801,8 @@ export const registerRoutes = async ( const identityService = identityServiceFactory({ permissionService, identityDAL, - identityOrgMembershipDAL + identityOrgMembershipDAL, + licenseService }); const identityAccessTokenService = identityAccessTokenServiceFactory({ identityAccessTokenDAL, diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 2863bf23eb..f5db1e9013 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { OrgMembershipRole, TOrgRoles } 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 { isAtLeastAsPrivileged } from "@app/lib/casl"; @@ -16,6 +17,7 @@ type TIdentityServiceFactoryDep = { identityDAL: TIdentityDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory; permissionService: Pick; + licenseService: Pick; }; export type TIdentityServiceFactory = ReturnType; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 68d2b8cda2..248bab5680 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -420,13 +420,20 @@ export const orgServiceFactory = ({ } 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 + if (plan?.memberLimit && plan.membersUsed >= plan.memberLimit) { + // limit imposed on number of members allowed / 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." }); } + + if (plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { + // limit imposed on number of identities allowed / number of identities used exceeds the number of identities 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.findUserByUsername(inviteeEmail, tx); if (inviteeUser) { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 422fe43f3c..89e36e91a9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -87,7 +87,7 @@ services: - 4000:4000 environment: - NODE_ENV=development - - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable + # - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable - TELEMETRY_ENABLED=false volumes: - ./backend/src:/app/src diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 66959ad1bc..3d4a6dc36b 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -2,6 +2,8 @@ export type SubscriptionPlan = { id: string; membersUsed: number; memberLimit: number; + identitiesUsed: number; + identityLimit: number; auditLogs: boolean; dynamicSecret: boolean; auditLogsRetentionDays: number; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 72351df88e..d4b1f17f44 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -652,19 +652,19 @@ export const AppLayout = ({ children }: LayoutProps) => { - {(window.location.origin.includes("https://app.infisical.com") || - window.location.origin.includes("https://gamma.infisical.com")) && ( - - - - Usage & Billing - - - - )} + {/* {(window.location.origin.includes("https://app.infisical.com") || + window.location.origin.includes("https://gamma.infisical.com")) && ( */} + + + + Usage & Billing + + + + {/* )} */} { + const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; - const { mutateAsync: deleteMutateAsync } = useDeleteIdentity(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "identity", @@ -31,6 +36,10 @@ export const IdentitySection = withPermission( "upgradePlan" ] as const); + const isMoreIdentitiesAllowed = subscription?.identityLimit + ? subscription.identitiesUsed < subscription.identityLimit + : true; + const onDeleteIdentitySubmit = async (identityId: string) => { try { await deleteMutateAsync({ @@ -81,7 +90,15 @@ export const IdentitySection = withPermission( colorSchema="primary" type="submit" leftIcon={} - onClick={() => handlePopUpOpen("identity")} + onClick={() => { + if (!isMoreIdentitiesAllowed) { + handlePopUpOpen("upgradePlan", { + description: "You can add more identities if you upgrade your Infisical plan." + }); + return; + } + handlePopUpOpen("identity"); + }} isDisabled={!isAllowed} > Create identity @@ -118,6 +135,11 @@ export const IdentitySection = withPermission( ) } /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> ); }, diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index 17f32369c5..8943f29402 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -23,7 +23,6 @@ import { AddOrgMemberModal } from "./AddOrgMemberModal"; import { OrgMembersTable } from "./OrgMembersTable"; export const OrgMembersSection = () => { - const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id ?? ""; @@ -39,9 +38,13 @@ export const OrgMembersSection = () => { const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership(); - const isMoreUsersNotAllowed = subscription?.memberLimit - ? subscription.membersUsed >= subscription.memberLimit - : false; + const isMoreUsersAllowed = subscription?.memberLimit + ? subscription.membersUsed < subscription.memberLimit + : true; + + const isMoreIdentitiesAllowed = subscription?.identityLimit + ? subscription.identitiesUsed < subscription.identityLimit + : true; const handleAddMemberModal = () => { if (currentOrg?.authEnforced) { @@ -52,13 +55,14 @@ export const OrgMembersSection = () => { return; } - if (isMoreUsersNotAllowed) { + if (!isMoreUsersAllowed || !isMoreIdentitiesAllowed) { handlePopUpOpen("upgradePlan", { description: "You can add more members if you upgrade your Infisical plan." }); - } else { - handlePopUpOpen("addMember"); + return; } + + handlePopUpOpen("addMember"); }; const onRemoveMemberSubmit = async (orgMembershipId: string) => { From d0f1cad98c701fb2100bbff620fd541ec09e2345 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 19 Jun 2024 13:59:47 -0700 Subject: [PATCH 2/3] Add support for identity-based pricing --- .../ldap-config/ldap-config-service.ts | 15 ++++++++ .../src/ee/services/license/license-dal.ts | 37 ++++++++++++++++++- .../ee/services/license/license-service.ts | 12 ++++-- .../saml-config/saml-config-service.ts | 15 ++++++++ .../src/services/identity/identity-service.ts | 17 ++++++++- 5 files changed, 89 insertions(+), 7 deletions(-) diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index dd49bd0aeb..546063132d 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -437,6 +437,21 @@ export const ldapConfigServiceFactory = ({ } }); } else { + const plan = await licenseService.getPlan(orgId); + if (plan?.memberLimit && plan.membersUsed >= plan.memberLimit) { + // limit imposed on number of members allowed / number of members used exceeds the number of members allowed + throw new BadRequestError({ + message: "Failed to create new member via LDAP due to member limit reached. Upgrade plan to add more members." + }); + } + + if (plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { + // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed + throw new BadRequestError({ + message: "Failed to create new member via LDAP due to member limit reached. Upgrade plan to add more members." + }); + } + userAlias = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; if (serverCfg.trustSamlEmails) { diff --git a/backend/src/ee/services/license/license-dal.ts b/backend/src/ee/services/license/license-dal.ts index cf70488019..4f37a4f93f 100644 --- a/backend/src/ee/services/license/license-dal.ts +++ b/backend/src/ee/services/license/license-dal.ts @@ -19,11 +19,44 @@ export const licenseDALFactory = (db: TDbClient) => { .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .where(`${TableName.Users}.isGhost`, false) .count(); - return doc?.[0].count; + return Number(doc?.[0].count); } catch (error) { throw new DatabaseError({ error, name: "Count of Org Members" }); } }; - return { countOfOrgMembers }; + const countOrgUsersAndIdentities = async (orgId: string | null, tx?: Knex) => { + try { + // count org users + const userDoc = await (tx || db)(TableName.OrgMembership) + .where({ status: OrgMembershipStatus.Accepted }) + .andWhere((bd) => { + if (orgId) { + void bd.where({ orgId }); + } + }) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .where(`${TableName.Users}.isGhost`, false) + .count(); + + const userCount = Number(userDoc?.[0].count); + + // count org identities + const identityDoc = await (tx || db)(TableName.IdentityOrgMembership) + .where((bd) => { + if (orgId) { + void bd.where({ orgId }); + } + }) + .count(); + + const identityCount = Number(identityDoc?.[0].count); + + return userCount + identityCount; + } catch (error) { + throw new DatabaseError({ error, name: "Count of Org Identities" }); + } + }; + + return { countOfOrgMembers, countOrgUsersAndIdentities }; }; diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 0819c461b1..0b0fec53ab 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -205,16 +205,22 @@ export const licenseServiceFactory = ({ const org = await orgDAL.findOrgById(orgId); if (!org) throw new BadRequestError({ message: "Org not found" }); - const count = await licenseDAL.countOfOrgMembers(orgId); + const quantity = await licenseDAL.countOfOrgMembers(orgId); + const quantityIdentities = await licenseDAL.countOrgUsersAndIdentities(orgId); if (org?.customerId) { await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { - quantity: count + quantity, + quantityIdentities }); } await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); } else if (instanceType === InstanceType.EnterpriseOnPrem) { const usedSeats = await licenseDAL.countOfOrgMembers(null); - await licenseServerOnPremApi.request.patch(`/api/license/v1/license`, { usedSeats }); + const usedIdentitySeats = await licenseDAL.countOrgUsersAndIdentities(null); + await licenseServerOnPremApi.request.patch(`/api/license/v1/license`, { + usedSeats, + usedIdentitySeats + }); } await refreshPlan(orgId); }; diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 5d7b7ec3b9..c4e6dc2bd5 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -377,6 +377,21 @@ export const samlConfigServiceFactory = ({ return foundUser; }); } else { + const plan = await licenseService.getPlan(orgId); + if (plan?.memberLimit && plan.membersUsed >= plan.memberLimit) { + // limit imposed on number of members allowed / number of members used exceeds the number of members allowed + throw new BadRequestError({ + message: "Failed to create new member via SAML due to member limit reached. Upgrade plan to add more members." + }); + } + + if (plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { + // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed + throw new BadRequestError({ + message: "Failed to create new member via SAML due to member limit reached. Upgrade plan to add more members." + }); + } + user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; if (serverCfg.trustSamlEmails) { diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index f5db1e9013..4659919fa8 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -17,7 +17,7 @@ type TIdentityServiceFactoryDep = { identityDAL: TIdentityDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory; permissionService: Pick; - licenseService: Pick; + licenseService: Pick; }; export type TIdentityServiceFactory = ReturnType; @@ -25,7 +25,8 @@ export type TIdentityServiceFactory = ReturnType; export const identityServiceFactory = ({ identityDAL, identityOrgMembershipDAL, - permissionService + permissionService, + licenseService }: TIdentityServiceFactoryDep) => { const createIdentity = async ({ name, @@ -47,6 +48,14 @@ export const identityServiceFactory = ({ const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); if (!hasRequiredPriviledges) throw new BadRequestError({ message: "Failed to create a more privileged identity" }); + const plan = await licenseService.getPlan(orgId); + if (plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { + // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed + throw new BadRequestError({ + message: "Failed to create identity due to identity limit reached. Upgrade plan to create more identities." + }); + } + const identity = await identityDAL.transaction(async (tx) => { const newIdentity = await identityDAL.create({ name }, tx); await identityOrgMembershipDAL.create( @@ -60,6 +69,7 @@ export const identityServiceFactory = ({ ); return newIdentity; }); + await licenseService.updateSubscriptionOrgMemberCount(orgId); return identity; }; @@ -152,6 +162,9 @@ export const identityServiceFactory = ({ throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); const deletedIdentity = await identityDAL.deleteById(id); + + await licenseService.updateSubscriptionOrgMemberCount(identityOrgMembership.orgId); + return { ...deletedIdentity, orgId: identityOrgMembership.orgId }; }; From 0a22a2a9eff568aba73f80ff45abfbf1f025f3ef Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 19 Jun 2024 14:16:32 -0700 Subject: [PATCH 3/3] Readjustments --- .../src/ee/services/license/license-dal.ts | 2 +- docker-compose.dev.yml | 2 +- frontend/src/layouts/AppLayout/AppLayout.tsx | 26 +++++++++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/src/ee/services/license/license-dal.ts b/backend/src/ee/services/license/license-dal.ts index 4f37a4f93f..5cbfca1d6f 100644 --- a/backend/src/ee/services/license/license-dal.ts +++ b/backend/src/ee/services/license/license-dal.ts @@ -54,7 +54,7 @@ export const licenseDALFactory = (db: TDbClient) => { return userCount + identityCount; } catch (error) { - throw new DatabaseError({ error, name: "Count of Org Identities" }); + throw new DatabaseError({ error, name: "Count of Org Users + Identities" }); } }; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 89e36e91a9..422fe43f3c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -87,7 +87,7 @@ services: - 4000:4000 environment: - NODE_ENV=development - # - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable + - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable - TELEMETRY_ENABLED=false volumes: - ./backend/src:/app/src diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index d4b1f17f44..72351df88e 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -652,19 +652,19 @@ export const AppLayout = ({ children }: LayoutProps) => { - {/* {(window.location.origin.includes("https://app.infisical.com") || - window.location.origin.includes("https://gamma.infisical.com")) && ( */} - - - - Usage & Billing - - - - {/* )} */} + {(window.location.origin.includes("https://app.infisical.com") || + window.location.origin.includes("https://gamma.infisical.com")) && ( + + + + Usage & Billing + + + + )}