[permissions] Add conditional permission gate on billing's checkoutSession (#10387)
Following a conversation with @etiennejouan and @martmull, we are adding a permission gate on billing resolver's checkoutSession, which should only be accessible to entitled users or at workspace creation (when there are no roles yet), when the subscription is incomplete
This commit is contained in:
@ -18,6 +18,7 @@ import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-pl
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service';
|
||||
import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
|
||||
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
|
||||
@ -25,11 +26,18 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
|
||||
@Resolver()
|
||||
@ -41,6 +49,8 @@ export class BillingResolver {
|
||||
private readonly stripePriceService: StripePriceService,
|
||||
private readonly billingPlanService: BillingPlanService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
@Query(() => BillingProductPricesOutput)
|
||||
@ -80,6 +90,7 @@ export class BillingResolver {
|
||||
async checkoutSession(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthUser() user: User,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@Args()
|
||||
{
|
||||
recurringInterval,
|
||||
@ -88,6 +99,10 @@ export class BillingResolver {
|
||||
requirePaymentMethod,
|
||||
}: BillingCheckoutSessionInput,
|
||||
) {
|
||||
await this.validateCanCheckoutSessionPermissionOrThrow({
|
||||
workspaceId: workspace.id,
|
||||
userWorkspaceId,
|
||||
});
|
||||
const isBillingPlansEnabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsBillingPlansEnabled,
|
||||
@ -158,4 +173,45 @@ export class BillingResolver {
|
||||
|
||||
return plans.map(formatBillingDatabaseProductToGraphqlDTO);
|
||||
}
|
||||
|
||||
private async validateCanCheckoutSessionPermissionOrThrow({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
userWorkspaceId: string;
|
||||
}) {
|
||||
const isPermissionsEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsPermissionsEnabled,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isPermissionsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
await this.billingService.isSubscriptionIncompleteOnboardingStatus(
|
||||
workspaceId,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userHasPermission =
|
||||
await this.permissionsService.userHasWorkspaceSettingPermission({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
_setting: SettingsFeatures.WORKSPACE,
|
||||
});
|
||||
|
||||
if (!userHasPermission) {
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
PermissionsExceptionCode.PERMISSION_DENIED,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ import { BillingSubscription } from 'src/engine/core-modules/billing/entities/bi
|
||||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
@ -18,7 +17,6 @@ export class BillingService {
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly isFeatureEnabledService: FeatureFlagService,
|
||||
@InjectRepository(BillingSubscription, 'core')
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||
) {}
|
||||
@ -56,4 +54,11 @@ export class BillingService {
|
||||
entitlementKey,
|
||||
);
|
||||
}
|
||||
|
||||
async isSubscriptionIncompleteOnboardingStatus(workspaceId: string) {
|
||||
const hasAnySubscription =
|
||||
await this.hasWorkspaceAnySubscription(workspaceId);
|
||||
|
||||
return !hasAnySubscription;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
export const isSubscriptionIncompleteOnboardingStatus = (
|
||||
hasAnySubscription: boolean,
|
||||
) => {
|
||||
return !hasAnySubscription;
|
||||
};
|
||||
@ -27,13 +27,6 @@ export class OnboardingService {
|
||||
private readonly userVarsService: UserVarsService<OnboardingKeyValueTypeMap>,
|
||||
) {}
|
||||
|
||||
private async isSubscriptionIncompleteOnboardingStatus(workspace: Workspace) {
|
||||
const hasAnySubscription =
|
||||
await this.billingService.hasWorkspaceAnySubscription(workspace.id);
|
||||
|
||||
return !hasAnySubscription;
|
||||
}
|
||||
|
||||
private isWorkspaceActivationPending(workspace: Workspace) {
|
||||
return (
|
||||
workspace.activationStatus === WorkspaceActivationStatus.PENDING_CREATION
|
||||
@ -41,7 +34,11 @@ export class OnboardingService {
|
||||
}
|
||||
|
||||
async getOnboardingStatus(user: User, workspace: Workspace) {
|
||||
if (await this.isSubscriptionIncompleteOnboardingStatus(workspace)) {
|
||||
if (
|
||||
await this.billingService.isSubscriptionIncompleteOnboardingStatus(
|
||||
workspace.id,
|
||||
)
|
||||
) {
|
||||
return OnboardingStatus.PLAN_REQUIRED;
|
||||
}
|
||||
|
||||
|
||||
@ -258,7 +258,7 @@ export class WorkspaceManagerService {
|
||||
|
||||
await this.userRoleService.assignRoleToUserWorkspace({
|
||||
workspaceId,
|
||||
userWorkspaceId: userWorkspace[0].id,
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
roleId: adminRole.id,
|
||||
});
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graph
|
||||
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
|
||||
|
||||
import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
|
||||
@ -406,6 +407,52 @@ describe('WorkspaceResolver', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkoutSession', () => {
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation CheckoutSession(
|
||||
$recurringInterval: SubscriptionInterval!
|
||||
$successUrlPath: String!
|
||||
$plan: BillingPlanKey!
|
||||
$requirePaymentMethod: Boolean
|
||||
) {
|
||||
checkoutSession(
|
||||
recurringInterval: $recurringInterval
|
||||
successUrlPath: $successUrlPath
|
||||
plan: $plan
|
||||
requirePaymentMethod: $requirePaymentMethod
|
||||
) {
|
||||
url
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
recurringInterval: 'Month',
|
||||
successUrlPath: '/settings/billing',
|
||||
plan: BillingPlanKey.PRO,
|
||||
requirePaymentMethod: true,
|
||||
},
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('lab', () => {
|
||||
|
||||
Reference in New Issue
Block a user