Generate GQL schema based on applicationId (#17860)

## Add application-scoped GraphQL schema generation

When an application token is used to authenticate, the `/graphql` schema
is now dynamically filtered to only include entities belonging to that
application (plus the Twenty Standard Application). This enables
third-party applications and the SDK to introspect a schema that is
relevant to their scope, rather than seeing the full workspace schema
with all custom objects.

### Changes

- **New `generateApplicationToken` mutation** on the `/metadata`
endpoint, allowing callers to exchange an API key for an
application-scoped JWT token
- **Schema filtering by application** in `WorkspaceSchemaFactory` — when
`request.application` is present (from an application token), flat
entity maps are filtered by `[appId, standardAppId]` before schema
generation
- **Per-app caching** — both the Yoga in-memory cache and Redis cache
now include the `appId` in their keys to avoid serving wrong schemas
- **Consolidated `getSubFlatEntityMapsByApplicationIdsOrThrow`** —
unified the single-ID and multi-ID filtering utilities into one
- **Integration tests** covering token generation (admin + API key auth)
and schema introspection filtering (standard app token excludes custom
objects)

Schema generated on seeds with applicationToken (see that pets is
missing)
<img width="782" height="994" alt="image"
src="https://github.com/user-attachments/assets/82510031-0965-435d-bc26-77c9f5d74e1f"
/>
This commit is contained in:
Charles Bochet
2026-02-11 20:21:58 +01:00
committed by GitHub
parent 15fc850212
commit 9bc63a01c9
16 changed files with 414 additions and 66 deletions
@@ -85,14 +85,14 @@ export class GraphQLConfigService
resolverSchemaScope: 'core',
buildSchemaOptions: {},
conditionalSchema: async (context) => {
const { workspace, user } = context.req;
const { workspace, user, application } = context.req;
try {
if (!isDefined(workspace)) {
return new GraphQLSchema({});
}
return await this.createSchema(context, workspace);
return await this.createSchema(context, workspace, application?.id);
} catch (error) {
if (error instanceof UnauthorizedException) {
throw new GraphQLError('Unauthenticated', {
@@ -159,6 +159,7 @@ export class GraphQLConfigService
async createSchema(
context: YogaDriverServerContext<'express'> & YogaInitialContext,
workspace: WorkspaceEntity,
applicationId?: string,
): Promise<GraphQLSchemaWithContext<YogaDriverServerContext<'express'>>> {
// Create a new contextId for each request
const contextId = ContextIdFactory.create();
@@ -177,6 +178,6 @@ export class GraphQLConfigService
},
);
return await workspaceFactory.createGraphQLSchema(workspace);
return await workspaceFactory.createGraphQLSchema(workspace, applicationId);
}
}
@@ -16,8 +16,14 @@ import {
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
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 { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
@Injectable()
export class WorkspaceSchemaFactory {
@@ -32,6 +38,7 @@ export class WorkspaceSchemaFactory {
async createGraphQLSchema(
workspace: WorkspaceEntity,
applicationId?: string,
): Promise<GraphQLSchema> {
const dataSourcesMetadata =
await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId(
@@ -42,32 +49,68 @@ export class WorkspaceSchemaFactory {
return new GraphQLSchema({});
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps, flatIndexMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: workspace.id,
flatMapsKeys: [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatIndexMaps',
],
},
);
const {
flatObjectMetadataMaps: allFlatObjectMetadataMaps,
flatFieldMetadataMaps: allFlatFieldMetadataMaps,
flatIndexMaps: allFlatIndexMaps,
flatApplicationMaps,
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: workspace.id,
flatMapsKeys: [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatIndexMaps',
'flatApplicationMaps',
],
},
);
if (!isDefined(flatObjectMetadataMaps)) {
if (!isDefined(allFlatObjectMetadataMaps)) {
throw new FlatEntityMapsException(
'Object metadata collection not found',
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
if (!isDefined(flatFieldMetadataMaps)) {
if (!isDefined(allFlatFieldMetadataMaps)) {
throw new FlatEntityMapsException(
'Field metadata collection not found',
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
let flatObjectMetadataMaps = allFlatObjectMetadataMaps;
let flatFieldMetadataMaps = allFlatFieldMetadataMaps;
let flatIndexMaps = allFlatIndexMaps;
if (isDefined(applicationId)) {
const twentyStandardApplicationId =
flatApplicationMaps?.idByUniversalIdentifier[
TWENTY_STANDARD_APPLICATION.universalIdentifier
];
const applicationIds = isDefined(twentyStandardApplicationId)
? [twentyStandardApplicationId, applicationId]
: [applicationId];
flatObjectMetadataMaps = this.filterFlatEntityMapsByApplicationIds(
allFlatObjectMetadataMaps,
applicationIds,
);
flatFieldMetadataMaps = this.filterFlatEntityMapsByApplicationIds(
allFlatFieldMetadataMaps,
applicationIds,
);
if (isDefined(allFlatIndexMaps)) {
flatIndexMaps = this.filterFlatEntityMapsByApplicationIds(
allFlatIndexMaps,
applicationIds,
);
}
}
let metadataVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
@@ -88,11 +131,13 @@ export class WorkspaceSchemaFactory {
let typeDefs = await this.workspaceCacheStorageService.getGraphQLTypeDefs(
workspace.id,
metadataVersion,
applicationId,
);
let usedScalarNames =
await this.workspaceCacheStorageService.getGraphQLUsedScalarNames(
workspace.id,
metadataVersion,
applicationId,
);
if (!typeDefs || !usedScalarNames) {
@@ -111,11 +156,13 @@ export class WorkspaceSchemaFactory {
workspace.id,
metadataVersion,
typeDefs,
applicationId,
);
await this.workspaceCacheStorageService.setGraphQLUsedScalarNames(
workspace.id,
metadataVersion,
usedScalarNames,
applicationId,
);
}
@@ -140,4 +187,16 @@ export class WorkspaceSchemaFactory {
return executableSchema;
}
private filterFlatEntityMapsByApplicationIds<
T extends FlatObjectMetadata | FlatFieldMetadata | FlatIndexMetadata,
>(
flatEntityMaps: FlatEntityMaps<T>,
applicationIds: string[],
): FlatEntityMaps<T> {
return getSubFlatEntityMapsByApplicationIdsOrThrow({
applicationIds,
flatEntityMaps,
});
}
}
@@ -7,6 +7,7 @@ import { MarketplaceResolver } from 'src/engine/core-modules/application/resolve
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
@@ -24,6 +25,7 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
TypeOrmModule.forFeature([FileEntity]),
ApplicationModule,
ApplicationVariableEntityModule,
TokenModule,
WorkspaceMigrationModule,
PermissionsModule,
ObjectPermissionModule,
@@ -0,0 +1,13 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ArgsType()
export class GenerateApplicationTokenInput {
@Field(() => UUIDScalarType)
@IsNotEmpty()
@IsUUID()
applicationId: string;
}
@@ -10,8 +10,11 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FileFolder } from 'twenty-shared/types';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import {
@@ -20,18 +23,22 @@ import {
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input';
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input';
import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input';
import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -39,7 +46,6 @@ import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/works
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input';
@UseGuards(
WorkspaceAuthGuard,
@@ -54,6 +60,8 @@ export class ApplicationResolver {
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationService: ApplicationService,
private readonly applicationTokenService: ApplicationTokenService,
private readonly twentyConfigService: TwentyConfigService,
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@@ -96,6 +104,33 @@ export class ApplicationResolver {
});
}
@Mutation(() => AuthToken)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async generateApplicationToken(
@Args() { applicationId }: GenerateApplicationTokenInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<AuthToken> {
const nodeEnv = this.twentyConfigService.get('NODE_ENV');
if (
nodeEnv !== NodeEnvironment.DEVELOPMENT &&
nodeEnv !== NodeEnvironment.TEST
) {
throw new ApplicationException(
'This endpoint is only available in development mode',
ApplicationExceptionCode.FORBIDDEN,
);
}
const APPLICATION_TOKEN_EXPIRY_SECONDS = 30 * 24 * 60 * 60;
return this.applicationTokenService.generateApplicationToken({
workspaceId,
applicationId,
expiresInSeconds: APPLICATION_TOKEN_EXPIRY_SECONDS,
});
}
@Mutation(() => ApplicationDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async createOneApplication(
@@ -7,7 +7,7 @@ import {
} from 'src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util';
import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getSubFlatEntityMapsByApplicationIdOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-id-or-throw.util';
import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util';
import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type';
export type FromToApplicationManifestAllUniversalFlatEntityMaps = {
@@ -34,10 +34,10 @@ export const getSubApplicationFromToAllFlatEntityMaps = ({
const toFlatEntityMaps = toAllUniversalFlatEntityMaps[flatEntityMapsKey];
const fromTo = {
from: getSubFlatEntityMapsByApplicationIdOrThrow<
from: getSubFlatEntityMapsByApplicationIdsOrThrow<
MetadataFlatEntity<typeof metadataName>
>({
applicationId,
applicationIds: [applicationId],
flatEntityMaps: fromFlatEntityMaps,
}),
to: toFlatEntityMaps,
@@ -3,22 +3,28 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ
import { findFlatEntitiesByApplicationId } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entities-by-application-id.util';
import { getSubFlatEntityByIdsMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-by-ids-maps-or-throw.util';
export const getSubFlatEntityMapsByApplicationIdOrThrow = <
export const getSubFlatEntityMapsByApplicationIdsOrThrow = <
T extends SyncableFlatEntity,
>({
applicationId,
applicationIds,
flatEntityMaps,
}: {
applicationId: string;
applicationIds: string[];
flatEntityMaps: FlatEntityMaps<T>;
}) => {
const allApplicationFlatEntity = findFlatEntitiesByApplicationId({
applicationId,
flatEntityMaps,
const allFlatEntityIds = applicationIds.flatMap((applicationId) => {
const entities = findFlatEntitiesByApplicationId({
applicationId,
flatEntityMaps,
});
return entities.map((entity) => entity.id);
});
const uniqueFlatEntityIds = [...new Set(allFlatEntityIds)];
return getSubFlatEntityByIdsMapsOrThrow({
flatEntityIds: allApplicationFlatEntity.map((flatEntity) => flatEntity.id),
flatEntityIds: uniqueFlatEntityIds,
flatEntityMaps,
});
};
@@ -91,9 +91,12 @@ export class WorkspaceCacheStorageService {
workspaceId: string,
metadataVersion: number,
typeDefs: string,
applicationId?: string,
): Promise<void> {
const applicationSuffix = applicationId ? `:${applicationId}` : '';
return this.cacheStorageService.set<string>(
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLTypeDefs}:${workspaceId}:${metadataVersion}`,
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLTypeDefs}:${workspaceId}:${metadataVersion}${applicationSuffix}`,
typeDefs,
TTL_ONE_WEEK,
);
@@ -102,9 +105,12 @@ export class WorkspaceCacheStorageService {
getGraphQLTypeDefs(
workspaceId: string,
metadataVersion: number,
applicationId?: string,
): Promise<string | undefined> {
const applicationSuffix = applicationId ? `:${applicationId}` : '';
return this.cacheStorageService.get<string>(
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLTypeDefs}:${workspaceId}:${metadataVersion}`,
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLTypeDefs}:${workspaceId}:${metadataVersion}${applicationSuffix}`,
);
}
@@ -112,9 +118,12 @@ export class WorkspaceCacheStorageService {
workspaceId: string,
metadataVersion: number,
usedScalarNames: string[],
applicationId?: string,
): Promise<void> {
const applicationSuffix = applicationId ? `:${applicationId}` : '';
return this.cacheStorageService.set<string[]>(
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLUsedScalarNames}:${workspaceId}:${metadataVersion}`,
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLUsedScalarNames}:${workspaceId}:${metadataVersion}${applicationSuffix}`,
usedScalarNames,
TTL_ONE_WEEK,
);
@@ -123,9 +132,12 @@ export class WorkspaceCacheStorageService {
getGraphQLUsedScalarNames(
workspaceId: string,
metadataVersion: number,
applicationId?: string,
): Promise<string[] | undefined> {
const applicationSuffix = applicationId ? `:${applicationId}` : '';
return this.cacheStorageService.get<string[]>(
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLUsedScalarNames}:${workspaceId}:${metadataVersion}`,
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLUsedScalarNames}:${workspaceId}:${metadataVersion}${applicationSuffix}`,
);
}
@@ -8,7 +8,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getSubFlatEntityMapsByApplicationIdOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-id-or-throw.util';
import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util';
import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -94,10 +94,10 @@ export class TwentyStandardApplicationService {
const fromFlatEntityMaps =
fromTwentyStandardAllFlatEntityMaps[flatEntityMapsKey];
const fromTo = {
from: getSubFlatEntityMapsByApplicationIdOrThrow<
from: getSubFlatEntityMapsByApplicationIdsOrThrow<
MetadataFlatEntity<typeof metadataName>
>({
applicationId: twentyStandardFlatApplication.id,
applicationIds: [twentyStandardFlatApplication.id],
flatEntityMaps: fromFlatEntityMaps,
}),
to: toTwentyStandardAllFlatEntityMaps[flatEntityMapsKey],