diff --git a/packages/server/modules/gatekeeper/domain/operations.ts b/packages/server/modules/gatekeeper/domain/operations.ts new file mode 100644 index 000000000..78cf4fef1 --- /dev/null +++ b/packages/server/modules/gatekeeper/domain/operations.ts @@ -0,0 +1,10 @@ +import { WorkspaceFeatureName } from '@/modules/gatekeeper/domain/workspacePricing' + +export type CanWorkspaceAccessFeature = (args: { + workspaceId: string + workspaceFeature: WorkspaceFeatureName +}) => Promise + +export type WorkspaceFeatureAccessFunction = (args: { + workspaceId: string +}) => Promise diff --git a/packages/server/modules/gatekeeper/domain/workspacePricing.ts b/packages/server/modules/gatekeeper/domain/workspacePricing.ts index e552fe102..9fc31db3d 100644 --- a/packages/server/modules/gatekeeper/domain/workspacePricing.ts +++ b/packages/server/modules/gatekeeper/domain/workspacePricing.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -type Features = +export type WorkspaceFeatureName = | 'domainBasedSecurityPolicies' | 'oidcSso' | 'workspaceDataRegionSpecificity' @@ -10,7 +10,7 @@ type FeatureDetails = { description?: string } -const features: Record = { +const features: Record = { domainBasedSecurityPolicies: { description: 'Email domain based security policies', displayName: 'Domain security policies' @@ -146,6 +146,11 @@ export const unpaidWorkspacePlanFeatures: Record< unlimited } +export const workspacePlanFeatures: Record< + WorkspacePlans, + WorkspacePlanFeaturesAndLimits +> = { ...paidWorkspacePlanFeatures, ...unpaidWorkspacePlanFeatures } + export const pricingTable = { workspacePricingPlanInformation, workspacePlanInformation: paidWorkspacePlanFeatures diff --git a/packages/server/modules/gatekeeper/services/featureAuthorization.ts b/packages/server/modules/gatekeeper/services/featureAuthorization.ts new file mode 100644 index 000000000..aa1ea5c0e --- /dev/null +++ b/packages/server/modules/gatekeeper/services/featureAuthorization.ts @@ -0,0 +1,53 @@ +import { GetWorkspacePlan } from '@/modules/gatekeeper/domain/billing' +import { + CanWorkspaceAccessFeature, + WorkspaceFeatureAccessFunction +} from '@/modules/gatekeeper/domain/operations' +import { workspacePlanFeatures } from '@/modules/gatekeeper/domain/workspacePricing' +import { WorkspacePlanNotFoundError } from '@/modules/gatekeeper/errors/billing' +import { throwUncoveredError } from '@speckle/shared' + +export const canWorkspaceAccessFeatureFactory = + ({ + getWorkspacePlan + }: { + getWorkspacePlan: GetWorkspacePlan + }): CanWorkspaceAccessFeature => + async ({ workspaceId, workspaceFeature }) => { + const workspacePlan = await getWorkspacePlan({ workspaceId }) + if (!workspacePlan) throw new WorkspacePlanNotFoundError() + switch (workspacePlan.status) { + case 'valid': + case 'trial': + case 'paymentFailed': + case 'cancelationScheduled': + break + case 'expired': + case 'canceled': + return false + default: + throwUncoveredError(workspacePlan) + } + return workspacePlanFeatures[workspacePlan.name][workspaceFeature] + } + +export const canWorkspaceUseOidcSsoFactory = + (deps: { getWorkspacePlan: GetWorkspacePlan }): WorkspaceFeatureAccessFunction => + async ({ workspaceId }) => + canWorkspaceAccessFeatureFactory(deps)({ workspaceId, workspaceFeature: 'oidcSso' }) + +export const canWorkspaceUseRegions = + (deps: { getWorkspacePlan: GetWorkspacePlan }): WorkspaceFeatureAccessFunction => + async ({ workspaceId }) => + canWorkspaceAccessFeatureFactory(deps)({ + workspaceId, + workspaceFeature: 'workspaceDataRegionSpecificity' + }) + +export const canWorkspaceUseDomainBasedSecurityPolicies = + (deps: { getWorkspacePlan: GetWorkspacePlan }): WorkspaceFeatureAccessFunction => + async ({ workspaceId }) => + canWorkspaceAccessFeatureFactory(deps)({ + workspaceId, + workspaceFeature: 'domainBasedSecurityPolicies' + }) diff --git a/packages/server/modules/gatekeeper/tests/unit/featureAuthorization.spec.ts b/packages/server/modules/gatekeeper/tests/unit/featureAuthorization.spec.ts new file mode 100644 index 000000000..6a09574e9 --- /dev/null +++ b/packages/server/modules/gatekeeper/tests/unit/featureAuthorization.spec.ts @@ -0,0 +1,52 @@ +import { WorkspacePlan } from '@/modules/gatekeeper/domain/billing' +import { WorkspacePlanNotFoundError } from '@/modules/gatekeeper/errors/billing' +import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/featureAuthorization' +import { expectToThrow } from '@/test/assertionHelper' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' + +describe('featureAuthorization @gatekeeper', () => { + describe('canWorkspaceAccessFeatureFactory creates a function, that', () => { + it('throws an error if workspace is not on a workspacePlan', async () => { + const canWorkspaceAccessFeature = canWorkspaceAccessFeatureFactory({ + getWorkspacePlan: async () => null + }) + const err = await expectToThrow( + async () => + await canWorkspaceAccessFeature({ + workspaceId: cryptoRandomString({ length: 10 }), + workspaceFeature: 'domainBasedSecurityPolicies' + }) + ) + expect(err.message).to.be.equal(new WorkspacePlanNotFoundError().message) + }) + ;( + [ + ['team', 'expired', 'oidcSso', false], + ['team', 'valid', 'oidcSso', false], + ['team', 'valid', 'workspaceDataRegionSpecificity', false], + ['pro', 'valid', 'workspaceDataRegionSpecificity', false], + ['pro', 'canceled', 'oidcSso', false], + ['pro', 'valid', 'oidcSso', true], + ['business', 'valid', 'workspaceDataRegionSpecificity', true] + ] as const + ).forEach(([plan, status, workspaceFeature, expectedResult]) => { + it(`returns ${expectedResult} for ${plan} @ ${status} for ${workspaceFeature}`, async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const canWorkspaceAccessFeature = canWorkspaceAccessFeatureFactory({ + getWorkspacePlan: async () => + ({ + name: plan, + status, + workspaceId + } as WorkspacePlan) + }) + const result = await canWorkspaceAccessFeature({ + workspaceId, + workspaceFeature + }) + expect(result).to.equal(expectedResult) + }) + }) + }) +})