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
@@ -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}`,
);
}