Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Identity-Based Pricing #1995

Merged
merged 5 commits into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/src/ee/services/ldap-config/ldap-config-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,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) {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/ee/services/license/__mocks__/licence-fns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const getDefaultOnPremFeatures = () => {
workspacesUsed: 0,
memberLimit: null,
membersUsed: 0,
identityLimit: null,
identitiesUsed: 0,
environmentLimit: null,
environmentsUsed: 0,
secretVersioning: true,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/ee/services/license/licence-fns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
membersUsed: 0,
environmentLimit: null,
environmentsUsed: 0,
identityLimit: null,
identitiesUsed: 0,
dynamicSecret: false,
secretVersioning: true,
pitRecovery: false,
Expand Down
37 changes: 35 additions & 2 deletions backend/src/ee/services/license/license-dal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
13 changes: 10 additions & 3 deletions backend/src/ee/services/license/license-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export const licenseServiceFactory = ({
LICENSE_SERVER_CLOUD_PLAN_TTL,
JSON.stringify(currentPlan)
);

return currentPlan;
}
} catch (error) {
Expand Down Expand Up @@ -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);
};
Expand Down
2 changes: 2 additions & 0 deletions backend/src/ee/services/license/license-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export type TFeatureSet = {
dynamicSecret: false;
memberLimit: null;
membersUsed: 0;
identityLimit: null;
identitiesUsed: 0;
environmentLimit: null;
environmentsUsed: 0;
secretVersioning: true;
Expand Down
15 changes: 15 additions & 0 deletions backend/src/ee/services/saml-config/saml-config-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion backend/src/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,8 @@ export const registerRoutes = async (
const identityService = identityServiceFactory({
permissionService,
identityDAL,
identityOrgMembershipDAL
identityOrgMembershipDAL,
licenseService
});
const identityAccessTokenService = identityAccessTokenServiceFactory({
identityAccessTokenDAL,
Expand Down
17 changes: 16 additions & 1 deletion backend/src/services/identity/identity-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,14 +17,16 @@ type TIdentityServiceFactoryDep = {
identityDAL: TIdentityDALFactory;
identityOrgMembershipDAL: TIdentityOrgDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
};

export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>;

export const identityServiceFactory = ({
identityDAL,
identityOrgMembershipDAL,
permissionService
permissionService,
licenseService
}: TIdentityServiceFactoryDep) => {
const createIdentity = async ({
name,
Expand All @@ -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(
Expand All @@ -58,6 +69,7 @@ export const identityServiceFactory = ({
);
return newIdentity;
});
await licenseService.updateSubscriptionOrgMemberCount(orgId);

return identity;
};
Expand Down Expand Up @@ -150,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 };
};

Expand Down
13 changes: 10 additions & 3 deletions backend/src/services/org/org-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/hooks/api/subscriptions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export type SubscriptionPlan = {
id: string;
membersUsed: number;
memberLimit: number;
identitiesUsed: number;
identityLimit: number;
auditLogs: boolean;
dynamicSecret: boolean;
auditLogsRetentionDays: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand All @@ -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({
Expand Down Expand Up @@ -81,7 +90,15 @@ export const IdentitySection = withPermission(
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
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
Expand Down Expand Up @@ -118,6 +135,11 @@ export const IdentitySection = withPermission(
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "";
Expand All @@ -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) {
Expand All @@ -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) => {
Expand Down
Loading