From 2fccd194f3dab7c702321227ea3e3ee1d899a92a Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:26:38 +0200 Subject: [PATCH] [Billing for self host] End dummy enterprise key validity (#19560) Screenshot 2026-04-10 at 16 40 07 Re-using hasValidEnterpriseKey to avoid breaking changes. This will be entirely removed in the next versions. --- .../components/InformationBannerWrapper.tsx | 4 +- ...InformationBannerInvalidEnterpriseKey.tsx} | 13 +++-- packages/twenty-server/.env.test | 3 +- .../__tests__/enterprise-plan.service.spec.ts | 12 ++-- .../services/enterprise-plan.service.ts | 47 ++++----------- .../metadata-modules/role/role.resolver.ts | 2 + ...-predicate-graphql-api-exception.filter.ts | 15 +++++ ...graphql-api-exception-handler.util.spec.ts | 57 +++++++++++++++++++ ...cate-graphql-api-exception-handler.util.ts | 37 ++++++++++++ 9 files changed, 140 insertions(+), 50 deletions(-) rename packages/twenty-front/src/modules/information-banner/components/enterprise/{InformationBannerLegacyEnterpriseKey.tsx => InformationBannerInvalidEnterpriseKey.tsx} (76%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/filters/row-level-permission-predicate-graphql-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/__tests__/row-level-permission-predicate-graphql-api-exception-handler.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util.ts diff --git a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx index 0fed9a286e..3cd550c9eb 100644 --- a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx +++ b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx @@ -6,7 +6,7 @@ import { InformationBannerBillingSubscriptionPaused } from '@/information-banner import { InformationBannerEndTrialPeriod } from '@/information-banner/components/billing/InformationBannerEndTrialPeriod'; import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo'; import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription'; -import { InformationBannerLegacyEnterpriseKey } from '@/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey'; +import { InformationBannerInvalidEnterpriseKey } from '@/information-banner/components/enterprise/InformationBannerInvalidEnterpriseKey'; import { InformationBannerMaintenance } from '@/information-banner/components/maintenance/InformationBannerMaintenance'; import { InformationBannerReconnectAccountEmailAliases } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases'; import { InformationBannerReconnectAccountInsufficientPermissions } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions'; @@ -55,7 +55,7 @@ export const InformationBannerWrapper = () => { return ( - + {isAccountSyncEnabled && ( )} diff --git a/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey.tsx b/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerInvalidEnterpriseKey.tsx similarity index 76% rename from packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey.tsx rename to packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerInvalidEnterpriseKey.tsx index a5d6a67d9d..c8842666a9 100644 --- a/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey.tsx +++ b/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerInvalidEnterpriseKey.tsx @@ -9,9 +9,9 @@ import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; import { IconKey } from 'twenty-ui/display'; -const COMPONENT_INSTANCE_ID = 'information-banner-legacy-enterprise-key'; +const COMPONENT_INSTANCE_ID = 'information-banner-invalid-enterprise-key'; -export const InformationBannerLegacyEnterpriseKey = () => { +export const InformationBannerInvalidEnterpriseKey = () => { const { t } = useLingui(); const navigate = useNavigate(); const currentWorkspace = useAtomStateValue(currentWorkspaceState); @@ -21,11 +21,12 @@ export const InformationBannerLegacyEnterpriseKey = () => { COMPONENT_INSTANCE_ID, ); - const hasLegacyKey = + const hasInvalidKey = currentWorkspace?.hasValidEnterpriseKey === true && - currentWorkspace?.hasValidSignedEnterpriseKey !== true; + currentWorkspace?.hasValidSignedEnterpriseKey !== true && + currentWorkspace?.hasValidEnterpriseValidityToken !== true; - if (!hasLegacyKey) { + if (!hasInvalidKey) { return null; } @@ -33,7 +34,7 @@ export const InformationBannerLegacyEnterpriseKey = () => { diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test index 6fdd1e8837..85a50cdd0d 100644 --- a/packages/twenty-server/.env.test +++ b/packages/twenty-server/.env.test @@ -11,7 +11,8 @@ MUTATION_MAXIMUM_RECORD_AFFECTED=100 IS_MULTIWORKSPACE_ENABLED=true FRONTEND_URL=http://localhost:3001 IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=false -ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key + +ENTERPRISE_VALIDITY_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZXYtc3Vic2NyaXB0aW9uLWlkIiwic3RhdHVzIjoidmFsaWQiLCJpYXQiOjE3NzMzMDg4MzMsImV4cCI6NDg5NzUxMTIzM30.qhfrW_SV2Y86fWtWXsALlAVhxmMxylUUIefN0fki10Q2NTGGqFVXZrNn2WacJY37yq3m5y4WgwZw34ua6E0ff_YUXsrlY5OHJWHT9DMqKCRn-JujHJnnYp3VHLncy5CvxH5r9mfPFp-5AWe1pYeR1T63sTiejH3sfDrNE357SB7KVti8LCcnsJxEtXB2tRnvyvdun7A-GKoKYEIam-16ZRKKFs6GaWo8ObHdfm8yBt6uK4DZSGPWb644QyWh9FtDxbzJ0ti54DuHSlErLgIp1NNEsMA0MK7zFY7StRaOdt72rxE1ZHwN7e6HhweTU4ORVUPfYkjDFLB2fF7Pa7Kvdg AUTH_GOOGLE_ENABLED=false MESSAGING_PROVIDER_GMAIL_ENABLED=false diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts b/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts index 2ed3ffbdbf..e886a26b3b 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts @@ -286,6 +286,7 @@ describe('EnterprisePlanService', () => { }); }); + // hasValidEnterpriseKey now means "has any ENTERPRISE_KEY configured" describe('hasValidEnterpriseKey', () => { it('should return true when signed enterprise key is valid', async () => { await setupValidState(); @@ -293,13 +294,12 @@ describe('EnterprisePlanService', () => { expect(service.hasValidEnterpriseKey()).toBe(true); }); - it('should return true with legacy unsigned key as fallback', async () => { + it('should return true with unsigned legacy key', async () => { setupEnterpriseKey('some-legacy-key'); mockCryptoVerify.mockReturnValue(false); appTokenFindOneMock.mockResolvedValue(null); await service.onModuleInit(); - expect(service.hasValidSignedEnterpriseKey()).toBe(false); expect(service.hasValidEnterpriseKey()).toBe(true); }); @@ -318,13 +318,13 @@ describe('EnterprisePlanService', () => { expect(service.isValid()).toBe(true); }); - it('should return true with legacy key as fallback', async () => { + it('should return false with unsigned legacy key', async () => { setupEnterpriseKey('some-legacy-key'); mockCryptoVerify.mockReturnValue(false); appTokenFindOneMock.mockResolvedValue(null); await service.onModuleInit(); - expect(service.isValid()).toBe(true); + expect(service.isValid()).toBe(false); }); it('should return false when no key or token exists', async () => { @@ -399,7 +399,7 @@ describe('EnterprisePlanService', () => { }); }); - it('should return legacy license info when only legacy key exists', async () => { + it('should return invalid license info when only unsigned legacy key exists', async () => { setupEnterpriseKey('some-legacy-key'); mockCryptoVerify.mockReturnValue(false); appTokenFindOneMock.mockResolvedValue(null); @@ -407,7 +407,7 @@ describe('EnterprisePlanService', () => { const licenseInfo = await service.getLicenseInfo(); expect(licenseInfo).toEqual({ - isValid: true, + isValid: false, licensee: null, expiresAt: null, subscriptionId: null, diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts b/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts index a38f036194..750ac2f8f4 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts @@ -143,40 +143,22 @@ export class EnterprisePlanService implements OnModuleInit { } hasValidEnterpriseKey(): boolean { - if (this.hasValidSignedEnterpriseKey()) { - return true; - } - - return this.checkLegacyKey(); + return this.hasValidSignedEnterpriseKey() || this.checkLegacyKey(); } isValid(): boolean { - if (this.hasValidEnterpriseValidityToken()) { - return true; - } + return this.hasValidEnterpriseValidityToken(); + } - return this.checkLegacyKey(); // temporary + private checkLegacyKey(): boolean { + // temporary + return isDefined(this.twentyConfigService.get('ENTERPRISE_KEY')); } isValidEnterpriseKeyFormat(key: string): boolean { return this.verifyJwt(key) !== null; } - private checkLegacyKey(): boolean { - const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY'); - - if (!isDefined(enterpriseKey)) { - return false; - } - - this.logger.warn( - 'Unsigned enterprise keys are deprecated and will stop working ' + - 'in a future version. Please obtain a signed key from twenty.com.', - ); - - return true; - } - async getLicenseInfo(): Promise { this.refreshKeyPayload(); await this.loadValidityToken(); @@ -192,15 +174,6 @@ export class EnterprisePlanService implements OnModuleInit { }; } - if (this.checkLegacyKey()) { - return { - isValid: true, - licensee: null, - expiresAt: null, - subscriptionId: null, - }; - } - return { isValid: false, licensee: null, @@ -477,11 +450,15 @@ export class EnterprisePlanService implements OnModuleInit { } } - // In development, try both keys so production keys work when testing locally + // In development and Jest integration tests, try both keys so production keys + // work locally private getPublicKeysToTry(): string[] { const nodeEnv = this.twentyConfigService.get('NODE_ENV'); - if (nodeEnv === NodeEnvironment.DEVELOPMENT) { + if ( + nodeEnv === NodeEnvironment.DEVELOPMENT || + nodeEnv === NodeEnvironment.TEST + ) { return [ENTERPRISE_JWT_PUBLIC_KEY, ENTERPRISE_JWT_DEV_PUBLIC_KEY]; } diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts index b8d5aa6500..19f43529e5 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts @@ -63,6 +63,7 @@ import { UpsertRowLevelPermissionPredicatesInput } from 'src/engine/metadata-mod 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 { UpsertRowLevelPermissionPredicatesResultDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/upsert-row-level-permission-predicates-result.dto'; +import { RowLevelPermissionPredicateGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/row-level-permission-predicate/filters/row-level-permission-predicate-graphql-api-exception.filter'; import { RowLevelPermissionPredicateGroupService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate-group.service'; import { RowLevelPermissionPredicateService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate.service'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; @@ -77,6 +78,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta SettingsPermissionGuard(PermissionFlagType.ROLES), ) @UseFilters( + RowLevelPermissionPredicateGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter, ) diff --git a/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/filters/row-level-permission-predicate-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/filters/row-level-permission-predicate-graphql-api-exception.filter.ts new file mode 100644 index 0000000000..c27ed9a8e6 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/filters/row-level-permission-predicate-graphql-api-exception.filter.ts @@ -0,0 +1,15 @@ +/* @license Enterprise */ + +import { Catch, type ExceptionFilter } from '@nestjs/common'; + +import { RowLevelPermissionPredicateException } from 'src/engine/metadata-modules/row-level-permission-predicate/exceptions/row-level-permission-predicate.exception'; +import { rowLevelPermissionPredicateGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util'; + +@Catch(RowLevelPermissionPredicateException) +export class RowLevelPermissionPredicateGraphqlApiExceptionFilter + implements ExceptionFilter +{ + catch(exception: RowLevelPermissionPredicateException) { + return rowLevelPermissionPredicateGraphqlApiExceptionHandler(exception); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/__tests__/row-level-permission-predicate-graphql-api-exception-handler.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/__tests__/row-level-permission-predicate-graphql-api-exception-handler.util.spec.ts new file mode 100644 index 0000000000..e5d5f3d913 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/__tests__/row-level-permission-predicate-graphql-api-exception-handler.util.spec.ts @@ -0,0 +1,57 @@ +import { + ForbiddenError, + InternalServerError, + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + RowLevelPermissionPredicateException, + RowLevelPermissionPredicateExceptionCode, +} from 'src/engine/metadata-modules/row-level-permission-predicate/exceptions/row-level-permission-predicate.exception'; +import { rowLevelPermissionPredicateGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util'; + +describe('rowLevelPermissionPredicateGraphqlApiExceptionHandler', () => { + it('should throw ForbiddenError for ROW_LEVEL_PERMISSION_FEATURE_DISABLED', () => { + const exception = new RowLevelPermissionPredicateException( + 'Row level permission predicate feature is disabled.', + RowLevelPermissionPredicateExceptionCode.ROW_LEVEL_PERMISSION_FEATURE_DISABLED, + ); + + expect(() => + rowLevelPermissionPredicateGraphqlApiExceptionHandler(exception), + ).toThrow(ForbiddenError); + }); + + it('should throw NotFoundError for ROW_LEVEL_PERMISSION_PREDICATE_NOT_FOUND', () => { + const exception = new RowLevelPermissionPredicateException( + 'Predicate not found', + RowLevelPermissionPredicateExceptionCode.ROW_LEVEL_PERMISSION_PREDICATE_NOT_FOUND, + ); + + expect(() => + rowLevelPermissionPredicateGraphqlApiExceptionHandler(exception), + ).toThrow(NotFoundError); + }); + + it('should throw UserInputError for INVALID_ROW_LEVEL_PERMISSION_PREDICATE_DATA', () => { + const exception = new RowLevelPermissionPredicateException( + 'Invalid data', + RowLevelPermissionPredicateExceptionCode.INVALID_ROW_LEVEL_PERMISSION_PREDICATE_DATA, + ); + + expect(() => + rowLevelPermissionPredicateGraphqlApiExceptionHandler(exception), + ).toThrow(UserInputError); + }); + + it('should throw InternalServerError for INTERNAL_SERVER_ERROR', () => { + const exception = new RowLevelPermissionPredicateException( + 'Unexpected', + RowLevelPermissionPredicateExceptionCode.INTERNAL_SERVER_ERROR, + ); + + expect(() => + rowLevelPermissionPredicateGraphqlApiExceptionHandler(exception), + ).toThrow(InternalServerError); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util.ts new file mode 100644 index 0000000000..0e698020ea --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util.ts @@ -0,0 +1,37 @@ +/* @license Enterprise */ + +import { assertUnreachable } from 'twenty-shared/utils'; + +import { + ForbiddenError, + InternalServerError, + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + type RowLevelPermissionPredicateException, + RowLevelPermissionPredicateExceptionCode, +} from 'src/engine/metadata-modules/row-level-permission-predicate/exceptions/row-level-permission-predicate.exception'; + +export const rowLevelPermissionPredicateGraphqlApiExceptionHandler = ( + error: RowLevelPermissionPredicateException, +) => { + switch (error.code) { + case RowLevelPermissionPredicateExceptionCode.ROW_LEVEL_PERMISSION_FEATURE_DISABLED: + case RowLevelPermissionPredicateExceptionCode.UNAUTHORIZED_ROLE_MODIFICATION: + case RowLevelPermissionPredicateExceptionCode.UNAUTHORIZED_OBJECT_MODIFICATION: + throw new ForbiddenError(error); + case RowLevelPermissionPredicateExceptionCode.INVALID_ROW_LEVEL_PERMISSION_PREDICATE_DATA: + throw new UserInputError(error); + case RowLevelPermissionPredicateExceptionCode.ROW_LEVEL_PERMISSION_PREDICATE_NOT_FOUND: + case RowLevelPermissionPredicateExceptionCode.FIELD_METADATA_NOT_FOUND: + case RowLevelPermissionPredicateExceptionCode.OBJECT_METADATA_NOT_FOUND: + case RowLevelPermissionPredicateExceptionCode.ROLE_NOT_FOUND: + throw new NotFoundError(error); + case RowLevelPermissionPredicateExceptionCode.INTERNAL_SERVER_ERROR: + throw new InternalServerError(error); + default: { + return assertUnreachable(error.code); + } + } +};