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:
+4
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user