Add RLS Entitlement check (#17179)

## Context
- Add RLS entitlement to billing
- Check value in the backend (for RLS predicate entity
queries/mutations)
- Expose billingEntitlements to the API inside currentWorkspace to check
available features to the workspace and display the role components
accordingly
- Cleanup RLS when plan changes back to one without RLS.

This should cover almost everything, imho we don't need to check in the
ORM because => We can't create RLS without the correct PLAN and
switching back to a PLAN without RLS deletes existing RLS through
stripes webhooks
This commit is contained in:
Weiko
2026-01-20 17:06:37 +01:00
committed by GitHub
parent 579c59bd11
commit b6635ba272
26 changed files with 314 additions and 24 deletions
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingWebhookController } from 'src/engine/core-modules/billing-webhook/billing-webhook.controller';
import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service';
import { BillingWebhookCreditGrantService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-credit-grant.service';
import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service';
import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service';
@@ -10,7 +11,6 @@ import { BillingWebhookPriceService } from 'src/engine/core-modules/billing-webh
import { BillingWebhookProductService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-product.service';
import { BillingWebhookSubscriptionScheduleService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-subscription-schedule.service';
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service';
import { BillingWebhookCreditGrantService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-credit-grant.service';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
@@ -27,6 +27,7 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RowLevelPermissionModule } from 'src/engine/metadata-modules/row-level-permission-predicate/row-level-permission.module';
@Module({
imports: [
@@ -48,6 +49,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
UserWorkspaceEntity,
FeatureFlagEntity,
]),
RowLevelPermissionModule,
],
controllers: [BillingWebhookController],
providers: [
@@ -14,6 +14,8 @@ import {
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { RowLevelPermissionPredicateGroupService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate-group.service';
@Injectable()
export class BillingWebhookEntitlementService {
@@ -22,6 +24,7 @@ export class BillingWebhookEntitlementService {
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
@InjectRepository(BillingEntitlementEntity)
private readonly billingEntitlementRepository: Repository<BillingEntitlementEntity>,
private readonly rowLevelPermissionPredicateGroupService: RowLevelPermissionPredicateGroupService,
) {}
async processStripeEvent(
@@ -40,17 +43,30 @@ export class BillingWebhookEntitlementService {
const workspaceId = billingCustomer.workspaceId;
await this.billingEntitlementRepository.upsert(
const billingEntitlements =
transformStripeEntitlementUpdatedEventToDatabaseEntitlement(
workspaceId,
data,
),
{
conflictPaths: ['workspaceId', 'key'],
skipUpdateIfNoValuesChanged: true,
},
);
await this.billingEntitlementRepository.upsert(billingEntitlements, {
conflictPaths: ['workspaceId', 'key'],
skipUpdateIfNoValuesChanged: true,
});
const isRowLevelPermissionDisabled = billingEntitlements.some(
(entitlement) =>
entitlement.workspaceId === workspaceId &&
entitlement.key === BillingEntitlementKey.RLS &&
entitlement.value === false,
);
if (isRowLevelPermissionDisabled) {
await this.rowLevelPermissionPredicateGroupService.deleteAllRowLevelPermissionPredicateGroups(
workspaceId,
);
}
return {
stripeEntitlementCustomerId: data.object.customer,
};
@@ -47,6 +47,12 @@ describe('transformStripeEntitlementUpdatedEventToDatabaseEntitlement', () => {
value: false,
workspaceId: 'workspaceId',
},
{
key: BillingEntitlementKey.RLS,
stripeCustomerId: 'cus_123',
value: false,
workspaceId: 'workspaceId',
},
]);
});
@@ -86,7 +92,13 @@ describe('transformStripeEntitlementUpdatedEventToDatabaseEntitlement', () => {
stripeCustomerId: 'cus_123',
},
{
key: 'CUSTOM_DOMAIN',
key: BillingEntitlementKey.CUSTOM_DOMAIN,
stripeCustomerId: 'cus_123',
value: false,
workspaceId: 'workspaceId',
},
{
key: BillingEntitlementKey.RLS,
stripeCustomerId: 'cus_123',
value: false,
workspaceId: 'workspaceId',
@@ -0,0 +1,18 @@
/* @license Enterprise */
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
registerEnumType(BillingEntitlementKey, {
name: 'BillingEntitlementKey',
});
@ObjectType('BillingEntitlement')
export class BillingEntitlementDTO {
@Field(() => BillingEntitlementKey)
key: BillingEntitlementKey;
@Field(() => Boolean)
value: boolean;
}
@@ -3,4 +3,5 @@
export enum BillingEntitlementKey {
SSO = 'SSO',
CUSTOM_DOMAIN = 'CUSTOM_DOMAIN',
RLS = 'RLS',
}
@@ -175,6 +175,22 @@ export class BillingSubscriptionService {
};
}
async getWorkspaceEntitlements(
workspaceId: string,
): Promise<BillingEntitlementEntity[]> {
const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
if (!isBillingEnabled) {
return [];
}
const entitlements = await this.billingEntitlementRepository.find({
where: { workspaceId },
});
return entitlements;
}
async getWorkspaceEntitlementByKey(
workspaceId: string,
key: BillingEntitlementKey,
@@ -27,6 +27,7 @@ import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { fromFlatApplicationToApplicationDto } from 'src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util';
import { BillingEntitlementDTO } from 'src/engine/core-modules/billing/dtos/billing-entitlement.dto';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
@@ -320,6 +321,13 @@ export class WorkspaceResolver {
return workspace.logo ?? '';
}
@ResolveField(() => [BillingEntitlementDTO])
billingEntitlements(@Parent() workspace: WorkspaceEntity) {
return this.billingSubscriptionService.getWorkspaceEntitlements(
workspace.id,
);
}
@ResolveField(() => Boolean)
hasValidEnterpriseKey(): boolean {
return isDefined(this.twentyConfigService.get('ENTERPRISE_KEY'));