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
File diff suppressed because one or more lines are too long
@@ -436,6 +436,18 @@ export type BillingEndTrialPeriodOutput = {
status?: Maybe<SubscriptionStatus>;
};
export type BillingEntitlement = {
__typename?: 'BillingEntitlement';
key: BillingEntitlementKey;
value: Scalars['Boolean'];
};
export enum BillingEntitlementKey {
CUSTOM_DOMAIN = 'CUSTOM_DOMAIN',
RLS = 'RLS',
SSO = 'SSO'
}
export type BillingLicensedProduct = BillingProductDto & {
__typename?: 'BillingLicensedProduct';
description: Scalars['String'];
@@ -5188,6 +5200,7 @@ export type Workspace = {
__typename?: 'Workspace';
activationStatus: WorkspaceActivationStatus;
allowImpersonation: Scalars['Boolean'];
billingEntitlements: Array<BillingEntitlement>;
billingSubscriptions: Array<BillingSubscription>;
createdAt: Scalars['DateTime'];
currentBillingSubscription?: Maybe<BillingSubscription>;
@@ -3,9 +3,9 @@ import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import { ApolloFactory, type Options } from '@/apollo/services/apollo.factory';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { WorkspaceActivationStatus } from '~/generated/graphql';
import { ApolloFactory, type Options } from '@/apollo/services/apollo.factory';
enableFetchMocks();
@@ -45,6 +45,7 @@ const mockWorkspace = {
allowImpersonation: false,
activationStatus: WorkspaceActivationStatus.ACTIVE,
billingSubscriptions: [],
billingEntitlements: [],
currentBillingSubscription: null,
workspaceMembersCount: 0,
isPublicInviteLinkEnabled: false,
@@ -15,6 +15,7 @@ export type CurrentWorkspace = Pick<
| 'featureFlags'
| 'activationStatus'
| 'billingSubscriptions'
| 'billingEntitlements'
| 'currentBillingSubscription'
| 'workspaceMembersCount'
| 'isPublicInviteLinkEnabled'
@@ -1,11 +1,11 @@
import { renderHook } from '@testing-library/react';
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { useColumnDefinitionsFromObjectMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import {
SubscriptionInterval,
SubscriptionStatus,
@@ -50,6 +50,7 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
metadata: {},
phases: [],
},
billingEntitlements: [],
billingSubscriptions: [
{
id: '1',
@@ -1,4 +1,3 @@
import { t } from '@lingui/core/macro';
import { RecordChip } from '@/object-record/components/RecordChip';
import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types/components/FormFieldPlaceholder';
import {
@@ -9,6 +8,7 @@ import { VariableChipStandalone } from '@/object-record/record-field/ui/form-typ
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
const StyledRecordChip = styled(RecordChip)`
margin: ${({ theme }) => theme.spacing(2)};
@@ -64,7 +64,5 @@ export const FormSingleRecordFieldChip = ({
);
}
return (
<StyledPlaceholder>{t`Select a ${objectNameSingular}`}</StyledPlaceholder>
);
return <StyledPlaceholder>{t`Select`}</StyledPlaceholder>;
};
@@ -160,8 +160,6 @@ export const FormSingleRecordPicker = ({
}
};
const objectNames = objectNameSingulars.join(' or ');
return (
<FormFieldInputContainer data-testid={testId}>
{label ? <InputLabel>{label}</InputLabel> : null}
@@ -214,7 +212,7 @@ export const FormSingleRecordPicker = ({
focusId={dropdownId}
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
emptyLabel={t`No ${objectNames}`}
emptyLabel={t`No records`}
onCancel={() => closeDropdown(dropdownId)}
onMorphItemSelected={handleMorphItemSelected}
objectNameSingulars={objectNameSingulars}
@@ -50,7 +50,7 @@ export const Elipsis: Story = {
export const Performance = getProfilingStory({
componentName: 'DateTimeFieldDisplay',
averageThresholdInMs: 0.15,
averageThresholdInMs: 0.2,
numberOfRuns: 30,
numberOfTestsPerRun: 30,
});
@@ -15,6 +15,8 @@ import { SettingsPath, type ViewFilterOperand } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import {
type BillingEntitlement,
BillingEntitlementKey,
FeatureFlagKey,
useFindOneAgentQuery,
} from '~/generated-metadata/graphql';
@@ -48,10 +50,21 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({
});
const featureFlagsMap = useFeatureFlagsMap();
const workspaceBillingEntitlements = currentWorkspace?.billingEntitlements;
const isRLSBillingEntitlementEnabled =
workspaceBillingEntitlements?.some(
(entitlement: BillingEntitlement) =>
entitlement.key === BillingEntitlementKey.RLS &&
entitlement.value === true,
) ?? false;
const isRowLevelPermissionPredicatesEnabled =
featureFlagsMap[
FeatureFlagKey.IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED
] && hasValidEnterpriseKey;
] &&
(hasValidEnterpriseKey || isRLSBillingEntitlementEnabled);
const objectMetadataItem = objectMetadata.objectMetadataItem;
@@ -20,7 +20,7 @@ const StyledFilterRow = styled.div`
`;
const StyledOperandSelectContainer = styled.div`
width: 46px;
width: 50px;
`;
type SettingsRolePermissionsObjectLevelRecordLevelPermissionFilterRowProps = {
@@ -23,7 +23,7 @@ const StyledText = styled.div`
const StyledContainer = styled.div`
align-items: start;
display: flex;
min-width: ${({ theme }) => theme.spacing(20)};
min-width: ${({ theme }) => theme.spacing(16)};
color: ${({ theme }) => theme.font.color.tertiary};
`;
@@ -78,6 +78,10 @@ export const USER_QUERY_FRAGMENT = gql`
billingSubscriptions {
...BillingSubscriptionFragment
}
billingEntitlements {
key
value
}
workspaceMembersCount
defaultRole {
...RoleFragment
@@ -126,6 +126,7 @@ export const mockCurrentWorkspace = {
},
],
},
billingEntitlements: [],
billingSubscriptions: [
{
__typename: 'BillingSubscription',
@@ -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'));
@@ -16,6 +16,8 @@ export const RowLevelPermissionPredicateGroupExceptionCode =
'INVALID_ROW_LEVEL_PERMISSION_PREDICATE_GROUP_DATA',
ROLE_NOT_FOUND: 'ROLE_NOT_FOUND',
UNAUTHORIZED_ROLE_MODIFICATION: 'UNAUTHORIZED_ROLE_MODIFICATION',
ROW_LEVEL_PERMISSION_FEATURE_DISABLED:
'ROW_LEVEL_PERMISSION_FEATURE_DISABLED',
} as const);
const rowLevelPermissionPredicateGroupExceptionUserFriendlyMessages: Record<
@@ -26,6 +28,7 @@ const rowLevelPermissionPredicateGroupExceptionUserFriendlyMessages: Record<
INVALID_ROW_LEVEL_PERMISSION_PREDICATE_GROUP_DATA: msg`Invalid row level permission predicate group data.`,
ROLE_NOT_FOUND: msg`Role not found.`,
UNAUTHORIZED_ROLE_MODIFICATION: msg`Cannot modify predicate group belonging to a different role.`,
ROW_LEVEL_PERMISSION_FEATURE_DISABLED: msg`Row level permission predicate feature is disabled.`,
INTERNAL_SERVER_ERROR: msg`An unexpected error occurred.`,
};
@@ -19,6 +19,8 @@ export const RowLevelPermissionPredicateExceptionCode =
ROLE_NOT_FOUND: 'ROLE_NOT_FOUND',
UNAUTHORIZED_ROLE_MODIFICATION: 'UNAUTHORIZED_ROLE_MODIFICATION',
UNAUTHORIZED_OBJECT_MODIFICATION: 'UNAUTHORIZED_OBJECT_MODIFICATION',
ROW_LEVEL_PERMISSION_FEATURE_DISABLED:
'ROW_LEVEL_PERMISSION_FEATURE_DISABLED',
} as const);
const rowLevelPermissionPredicateExceptionUserFriendlyMessages: Record<
@@ -32,6 +34,7 @@ const rowLevelPermissionPredicateExceptionUserFriendlyMessages: Record<
ROLE_NOT_FOUND: msg`Role not found.`,
UNAUTHORIZED_ROLE_MODIFICATION: msg`Cannot modify predicate belonging to a different role.`,
UNAUTHORIZED_OBJECT_MODIFICATION: msg`Cannot modify predicate belonging to a different object.`,
ROW_LEVEL_PERMISSION_FEATURE_DISABLED: msg`Row level permission predicate feature is disabled.`,
INTERNAL_SERVER_ERROR: msg`An unexpected error occurred.`,
};
@@ -3,6 +3,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity';
import { RowLevelPermissionPredicateEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate.entity';
@@ -22,6 +23,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceMigrationModule,
BillingModule,
],
providers: [
RowLevelPermissionPredicateService,
@@ -1,9 +1,14 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -17,6 +22,11 @@ import { type DeleteRowLevelPermissionPredicateGroupInput } from 'src/engine/met
import { type DestroyRowLevelPermissionPredicateGroupInput } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/inputs/destroy-row-level-permission-predicate-group.input';
import { type UpdateRowLevelPermissionPredicateGroupInput } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/inputs/update-row-level-permission-predicate-group.input';
import { RowLevelPermissionPredicateGroupDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/row-level-permission-predicate-group.dto';
import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity';
import {
RowLevelPermissionPredicateGroupException,
RowLevelPermissionPredicateGroupExceptionCode,
} from 'src/engine/metadata-modules/row-level-permission-predicate/exceptions/row-level-permission-predicate-group.exception';
import { type FlatRowLevelPermissionPredicateGroup } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
@@ -28,6 +38,10 @@ export class RowLevelPermissionPredicateGroupService {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly billingService: BillingService,
@InjectRepository(RowLevelPermissionPredicateGroupEntity)
private readonly rowLevelPermissionPredicateGroupRepository: Repository<RowLevelPermissionPredicateGroupEntity>,
private readonly configService: ConfigService,
) {}
async createOne({
@@ -37,6 +51,8 @@ export class RowLevelPermissionPredicateGroupService {
createRowLevelPermissionPredicateGroupInput: CreateRowLevelPermissionPredicateGroupInput;
workspaceId: string;
}): Promise<RowLevelPermissionPredicateGroupDTO> {
await this.hasRowLevelPermissionFeatureOrThrow(workspaceId);
const flatGroupToCreate =
fromCreateRowLevelPermissionPredicateGroupInputToFlatRowLevelPermissionPredicateGroupToCreate(
{
@@ -73,6 +89,8 @@ export class RowLevelPermissionPredicateGroupService {
updateRowLevelPermissionPredicateGroupInput: UpdateRowLevelPermissionPredicateGroupInput;
workspaceId: string;
}): Promise<RowLevelPermissionPredicateGroupDTO> {
await this.hasRowLevelPermissionFeatureOrThrow(workspaceId);
const { flatRowLevelPermissionPredicateGroupMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -117,6 +135,8 @@ export class RowLevelPermissionPredicateGroupService {
deleteRowLevelPermissionPredicateGroupInput: DeleteRowLevelPermissionPredicateGroupInput;
workspaceId: string;
}): Promise<RowLevelPermissionPredicateGroupDTO> {
await this.hasRowLevelPermissionFeatureOrThrow(workspaceId);
const { flatRowLevelPermissionPredicateGroupMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -161,6 +181,8 @@ export class RowLevelPermissionPredicateGroupService {
destroyRowLevelPermissionPredicateGroupInput: DestroyRowLevelPermissionPredicateGroupInput;
workspaceId: string;
}): Promise<RowLevelPermissionPredicateGroupDTO> {
await this.hasRowLevelPermissionFeatureOrThrow(workspaceId);
const { flatRowLevelPermissionPredicateGroupMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -188,6 +210,13 @@ export class RowLevelPermissionPredicateGroupService {
async findByWorkspaceId(
workspaceId: string,
): Promise<RowLevelPermissionPredicateGroupDTO[]> {
const hasRowLevelPermissionFeature =
await this.hasRowLevelPermissionFeature(workspaceId);
if (!hasRowLevelPermissionFeature) {
return [];
}
const { flatRowLevelPermissionPredicateGroupMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -211,6 +240,13 @@ export class RowLevelPermissionPredicateGroupService {
workspaceId: string,
roleId: string,
): Promise<RowLevelPermissionPredicateGroupDTO[]> {
const hasRowLevelPermissionFeature =
await this.hasRowLevelPermissionFeature(workspaceId);
if (!hasRowLevelPermissionFeature) {
return [];
}
const { flatRowLevelPermissionPredicateGroupMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -234,6 +270,13 @@ export class RowLevelPermissionPredicateGroupService {
id: string,
workspaceId: string,
): Promise<RowLevelPermissionPredicateGroupDTO | null> {
const hasRowLevelPermissionFeature =
await this.hasRowLevelPermissionFeature(workspaceId);
if (!hasRowLevelPermissionFeature) {
return null;
}
const { flatRowLevelPermissionPredicateGroupMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -254,6 +297,18 @@ export class RowLevelPermissionPredicateGroupService {
return fromFlatRowLevelPermissionPredicateGroupToDto(flatGroup);
}
public async deleteAllRowLevelPermissionPredicateGroups(workspaceId: string) {
await this.rowLevelPermissionPredicateGroupRepository.delete({
workspaceId,
});
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'rolesPermissions',
'flatRowLevelPermissionPredicateMaps',
'flatRowLevelPermissionPredicateGroupMaps',
]);
}
private async runMigration({
workspaceId,
flatEntityToCreate = [],
@@ -292,4 +347,35 @@ export class RowLevelPermissionPredicateGroupService {
'flatRowLevelPermissionPredicateMaps',
]);
}
private async hasRowLevelPermissionFeature(
workspaceId: string,
): Promise<boolean> {
const isBillingEnabled = this.configService.get('IS_BILLING_ENABLED');
const entrepriseKey = this.configService.get('ENTERPRISE_KEY');
const isRowLevelPermissionEnabled =
await this.billingService.hasEntitlement(
workspaceId,
BillingEntitlementKey.RLS,
);
if (isDefined(entrepriseKey)) {
return true;
}
return isBillingEnabled && isRowLevelPermissionEnabled;
}
private async hasRowLevelPermissionFeatureOrThrow(workspaceId: string) {
const hasRowLevelPermissionFeature =
await this.hasRowLevelPermissionFeature(workspaceId);
if (!hasRowLevelPermissionFeature) {
throw new RowLevelPermissionPredicateGroupException(
'Row level permission predicate feature is disabled',
RowLevelPermissionPredicateGroupExceptionCode.ROW_LEVEL_PERMISSION_FEATURE_DISABLED,
);
}
}
}
@@ -1,10 +1,13 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
@@ -26,6 +29,10 @@ import {
} from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/inputs/upsert-row-level-permission-predicates.input';
import { RowLevelPermissionPredicateGroupDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/row-level-permission-predicate-group.dto';
import { RowLevelPermissionPredicateDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/row-level-permission-predicate.dto';
import {
RowLevelPermissionPredicateException,
RowLevelPermissionPredicateExceptionCode,
} from 'src/engine/metadata-modules/row-level-permission-predicate/exceptions/row-level-permission-predicate.exception';
import { type FlatRowLevelPermissionPredicateGroup } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group.type';
import { type FlatRowLevelPermissionPredicate } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@@ -38,6 +45,8 @@ export class RowLevelPermissionPredicateService {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly billingService: BillingService,
private readonly configService: ConfigService,
) {}
async createOne({
@@ -47,6 +56,8 @@ export class RowLevelPermissionPredicateService {
createRowLevelPermissionPredicateInput: CreateRowLevelPermissionPredicateInput;
workspaceId: string;
}): Promise<RowLevelPermissionPredicateDTO> {
await this.hasRowLevelPermissionFeatureOrThrow(workspaceId);
const flatPredicateToCreate =
fromCreateRowLevelPermissionPredicateInputToFlatRowLevelPermissionPredicateToCreate(
{
@@ -83,6 +94,8 @@ export class RowLevelPermissionPredicateService {
updateRowLevelPermissionPredicateInput: UpdateRowLevelPermissionPredicateInput;
workspaceId: string;
}): Promise<RowLevelPermissionPredicateDTO> {
await this.hasRowLevelPermissionFeatureOrThrow(workspaceId);
const { flatRowLevelPermissionPredicateMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -127,6 +140,8 @@ export class RowLevelPermissionPredicateService {
deleteRowLevelPermissionPredicateInput: DeleteRowLevelPermissionPredicateInput;
workspaceId: string;
}): Promise<RowLevelPermissionPredicateDTO> {
await this.hasRowLevelPermissionFeatureOrThrow(workspaceId);
const { flatRowLevelPermissionPredicateMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -171,6 +186,8 @@ export class RowLevelPermissionPredicateService {
destroyRowLevelPermissionPredicateInput: DestroyRowLevelPermissionPredicateInput;
workspaceId: string;
}): Promise<RowLevelPermissionPredicateDTO> {
await this.hasRowLevelPermissionFeatureOrThrow(workspaceId);
const { flatRowLevelPermissionPredicateMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -198,6 +215,13 @@ export class RowLevelPermissionPredicateService {
async findByWorkspaceId(
workspaceId: string,
): Promise<RowLevelPermissionPredicateDTO[]> {
const hasRowLevelPermissionFeature =
await this.hasRowLevelPermissionFeature(workspaceId);
if (!hasRowLevelPermissionFeature) {
return [];
}
const { flatRowLevelPermissionPredicateMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -222,6 +246,13 @@ export class RowLevelPermissionPredicateService {
roleId: string,
objectMetadataId: string,
): Promise<RowLevelPermissionPredicateDTO[]> {
const hasRowLevelPermissionFeature =
await this.hasRowLevelPermissionFeature(workspaceId);
if (!hasRowLevelPermissionFeature) {
return [];
}
const { flatRowLevelPermissionPredicateMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -250,6 +281,13 @@ export class RowLevelPermissionPredicateService {
id: string,
workspaceId: string,
): Promise<RowLevelPermissionPredicateDTO | null> {
const hasRowLevelPermissionFeature =
await this.hasRowLevelPermissionFeature(workspaceId);
if (!hasRowLevelPermissionFeature) {
return null;
}
const { flatRowLevelPermissionPredicateMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -280,6 +318,8 @@ export class RowLevelPermissionPredicateService {
predicates: RowLevelPermissionPredicateDTO[];
predicateGroups: RowLevelPermissionPredicateGroupDTO[];
}> {
await this.hasRowLevelPermissionFeatureOrThrow(workspaceId);
const { roleId, objectMetadataId, predicates, predicateGroups } = input;
const {
@@ -653,4 +693,35 @@ export class RowLevelPermissionPredicateService {
'flatRowLevelPermissionPredicateGroupMaps',
]);
}
private async hasRowLevelPermissionFeature(
workspaceId: string,
): Promise<boolean> {
const isBillingEnabled = this.configService.get('IS_BILLING_ENABLED');
const entrepriseKey = this.configService.get('ENTERPRISE_KEY');
const isRowLevelPermissionEnabled =
await this.billingService.hasEntitlement(
workspaceId,
BillingEntitlementKey.RLS,
);
if (isDefined(entrepriseKey)) {
return true;
}
return isBillingEnabled && isRowLevelPermissionEnabled;
}
private async hasRowLevelPermissionFeatureOrThrow(workspaceId: string) {
const hasRowLevelPermissionFeature =
await this.hasRowLevelPermissionFeature(workspaceId);
if (!hasRowLevelPermissionFeature) {
throw new RowLevelPermissionPredicateException(
'Row level permission predicate feature is disabled',
RowLevelPermissionPredicateExceptionCode.ROW_LEVEL_PERMISSION_FEATURE_DISABLED,
);
}
}
}
@@ -13,6 +13,7 @@ import {
type EmailsFilter,
type FloatFilter,
type FullNameFilter,
type IsFilter,
type LeafObjectRecordFilter,
type LinksFilter,
type MultiSelectFilter,
@@ -210,6 +211,10 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
const recordFieldValue = record[filterKey];
if (!isDefined(recordFieldValue)) {
if (isObject(filterValue)) {
return (filterValue as { is?: IsFilter })?.is === 'NULL';
}
return false;
}