diff --git a/.infisicalignore b/.infisicalignore index 855047fe4c..b7fc38b356 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -5,3 +5,4 @@ frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/M frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 docs/self-hosting/configuration/envars.mdx:generic-api-key:106 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:451 +docs/mint.json:generic-api-key:651 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 63565d94c2..b63ecda109 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -450,6 +450,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/__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 46cc656d5a..d69f7bf955 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-dal.ts b/backend/src/ee/services/license/license-dal.ts index cf70488019..5cbfca1d6f 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 Users + 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 46931468fd..0b0fec53ab 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) { @@ -204,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/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 9e1bdd9b32..36b03ff803 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/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 3cc51e1c20..08a13bf067 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -380,6 +380,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/server/routes/index.ts b/backend/src/server/routes/index.ts index dfcbf82cfa..a748396d4b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -806,7 +806,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 7d44cfdd4e..f6eccfb99e 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, TableName, 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; @@ -23,7 +25,8 @@ export type TIdentityServiceFactory = ReturnType; export const identityServiceFactory = ({ identityDAL, identityOrgMembershipDAL, - permissionService + permissionService, + licenseService }: TIdentityServiceFactoryDep) => { const createIdentity = async ({ name, @@ -45,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( @@ -58,6 +69,7 @@ export const identityServiceFactory = ({ ); return newIdentity; }); + await licenseService.updateSubscriptionOrgMemberCount(orgId); return identity; }; @@ -168,6 +180,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 }; }; 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/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 6b94258000..89635a9533 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/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx index c5ebf7c55b..b8b1f1708c 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx @@ -4,8 +4,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Button, DeleteActionModal } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription +} from "@app/context"; import { withPermission } from "@app/hoc"; import { useDeleteIdentity } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -17,10 +22,10 @@ import { IdentityUniversalAuthClientSecretModal } from "./IdentityUniversalAuthC export const IdentitySection = withPermission( () => { + 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) => {