[Billing for self host] End dummy enterprise key validity (#19560)

<img width="1504" height="755" alt="Screenshot 2026-04-10 at 16 40 07"
src="https://github.com/user-attachments/assets/68a12e40-a077-48df-9e18-885493520a32"
/>


Re-using hasValidEnterpriseKey to avoid breaking changes. 
This will be entirely removed in the next versions.
This commit is contained in:
Marie
2026-04-15 15:26:38 +02:00
committed by GitHub
parent 762fb6fd64
commit 2fccd194f3
9 changed files with 140 additions and 50 deletions
@@ -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 (
<StyledInformationBannerWrapper>
<InformationBannerMaintenance />
<InformationBannerLegacyEnterpriseKey />
<InformationBannerInvalidEnterpriseKey />
{isAccountSyncEnabled && (
<InformationBannerReconnectAccountInsufficientPermissions />
)}
@@ -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 = () => {
<InformationBanner
componentInstanceId={COMPONENT_INSTANCE_ID}
variant="secondary"
message={t`Your enterprise key format is deprecated. Please activate a new key to keep enterprise features.`}
message={t`Your enterprise key is no longer valid. Activate a new key to continue using enterprise features.`}
buttonTitle={t`Activate`}
buttonIcon={IconKey}
buttonOnClick={() =>
+2 -1
View File
@@ -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
@@ -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,
@@ -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<EnterpriseKeyPayload>(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<EnterpriseLicenseInfo> {
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];
}
@@ -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,
)
@@ -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);
}
}
@@ -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);
});
});
@@ -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);
}
}
};