1895 extensibility v1 application tokens 3 (#16504)

- moves applicationRoleId to application entity
- add new `APPLICATION` FieldActorSource and `APPLICATION`
JwtTokenTypeEnum value
- create a new token with applicationId when executing a function
- when applicationId is in token, check for application.defaultRole
permissions
-use twenty-shared types in `twenty-sdk/application`
- create a new import from generate called "Twenty" that you can use
directly without having to set TWENTY_API_KEY AND TWENTY_API_URL (keep
metadata or core parameter only)
- provide to serverless unique one time BEARER TOKEN to run it

Result
<img width="977" height="566" alt="image"
src="https://github.com/user-attachments/assets/e78428a0-5b13-4975-aa13-58ee3b32450c"
/>

<img width="910" height="596" alt="image"
src="https://github.com/user-attachments/assets/6ec72bf5-7655-4093-a45e-ad269595a324"
/>

<img width="741" height="568" alt="image"
src="https://github.com/user-attachments/assets/7683944c-fd79-4417-8fb2-8e4815cc112f"
/>
This commit is contained in:
martmull
2025-12-15 17:44:23 +01:00
committed by GitHub
parent e33f18bfa8
commit e289f3056e
103 changed files with 1427 additions and 512 deletions
+3 -1
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test, build]
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -39,6 +39,8 @@ jobs:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Build
run: npx nx build create-twenty-app
- name: Run ${{ matrix.task }} task
uses: ./.github/workflows/actions/nx-affected
with:
+3 -1
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test, build]
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -39,6 +39,8 @@ jobs:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Build
run: npx nx build twenty-sdk
- name: Run ${{ matrix.task }} task
uses: ./.github/workflows/actions/nx-affected
with:
@@ -1,6 +1,6 @@
{
"name": "hello-world",
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -17,7 +17,7 @@
"auth": "twenty auth login"
},
"dependencies": {
"twenty-sdk": "0.1.2"
"twenty-sdk": "0.2.0"
},
"devDependencies": {
"@types/node": "^24.7.2"
@@ -1,21 +1,18 @@
import { type FunctionConfig } from 'twenty-sdk';
import { createClient } from '../../generated';
import Twenty from '../../generated';
export const main = async (params: { recipient?: string }) => {
try {
const client = createClient({
url: `${process.env.TWENTY_API_URL}/graphql`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
});
const client = new Twenty();
const createPostCard = await client.mutation({
createPostCard: {
__args: {
data: {
name: params.recipient ?? 'Hello-world',
name:
params.recipient ??
process.env.DEFAULT_RECIPIENT_NAME ??
'Hello world',
},
},
name: true,
@@ -6,17 +6,14 @@ const config: ApplicationConfig = {
description: 'A simple hello world app',
icon: 'IconWorld',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
description: 'Twenty API Key',
isSecret: true,
},
TWENTY_API_URL: {
universalIdentifier: 'ef8ab489-e68a-4841-b402-261f440e6185',
description: 'Twenty API Url',
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
description: 'Default recipient name for postcards',
value: 'Alex Karp',
isSecret: false,
},
},
functionRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
};
export default config;
@@ -0,0 +1,34 @@
import { PermissionFlag, type RoleConfig } from 'twenty-sdk';
export const functionRole: RoleConfig = {
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
description: 'Default role for function Twenty client',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
canBeAssignedToApplications: true,
objectPermissions: [
{
objectNameSingular: 'postCard',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [
{
objectNameSingular: 'postCard',
fieldName: 'content',
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
};
+1 -1
View File
@@ -11,7 +11,7 @@ const modulesCoverage = {
branches: 25,
statements: 43,
lines: 44,
functions: 38,
functions: 37,
include: ['src/modules/**/*'],
exclude: ['src/**/*.ts'],
};
@@ -241,6 +241,8 @@ export type Application = {
agents: Array<Agent>;
applicationVariables: Array<ApplicationVariable>;
canBeUninstalled: Scalars['Boolean'];
defaultServerlessFunctionRole?: Maybe<Role>;
defaultServerlessFunctionRoleId?: Maybe<Scalars['String']>;
description: Scalars['String'];
id: Scalars['UUID'];
name: Scalars['String'];
@@ -241,6 +241,8 @@ export type Application = {
agents: Array<Agent>;
applicationVariables: Array<ApplicationVariable>;
canBeUninstalled: Scalars['Boolean'];
defaultServerlessFunctionRole?: Maybe<Role>;
defaultServerlessFunctionRoleId?: Maybe<Scalars['String']>;
description: Scalars['String'];
id: Scalars['UUID'];
name: Scalars['String'];
@@ -36,6 +36,7 @@ export const useActorFieldDisplay = (): ActorFieldDisplayValue | undefined => {
].find(
(workspaceMember) => workspaceMember.id === fieldValue.workspaceMemberId,
);
if (!isDefined(relatedWorkspaceMember)) {
return {
fieldValue,
@@ -284,6 +284,7 @@ const FieldActorSourceSchema = z.union([
z.literal('WORKFLOW'),
z.literal('WEBHOOK'),
z.literal('AGENT'),
z.literal('APPLICATION'),
]);
export const FieldActorValueSchema = z.object({
@@ -25,10 +25,10 @@ import {
IconServer,
IconSettings,
IconSparkles,
IconPuzzle2,
IconUserCircle,
IconUsers,
IconWorld,
IconPlug,
} from 'twenty-ui/display';
import { FeatureFlagKey, PermissionFlagType } from '~/generated/graphql';
@@ -157,7 +157,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
{
label: t`Applications`,
path: SettingsPath.Applications,
Icon: IconPuzzle2,
Icon: IconPlug,
isHidden:
!isApplicationEnabled ||
!permissionMap[PermissionFlagType.WORKSPACE],
@@ -12,6 +12,7 @@ import {
IconMail,
IconMicrosoftCalendar,
IconMicrosoftOutlook,
IconPlug,
IconRobot,
IconSettingsAutomation,
IconWebhook,
@@ -62,6 +63,8 @@ export const ActorDisplay = ({
return IconSettingsAutomation;
case 'WEBHOOK':
return IconWebhook;
case 'APPLICATION':
return IconPlug;
default:
return undefined;
}
@@ -1,14 +1,3 @@
import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type';
import { type Application } from 'twenty-shared/application';
type ApplicationVariable = SyncableEntityOptions & {
value?: string;
description?: string;
isSecret?: boolean;
};
export type ApplicationConfig = SyncableEntityOptions & {
displayName?: string;
description?: string;
icon?: string;
applicationVariables?: Record<string, ApplicationVariable>;
};
export type ApplicationConfig = Application;
@@ -1,31 +1,8 @@
import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type';
import {
type FieldMetadataType,
type FieldMetadataSettings,
type FieldMetadataOptions,
type FieldMetadataDefaultValue,
} from 'twenty-shared/types';
type FieldOptions<
T extends FieldMetadataType = Exclude<
FieldMetadataType,
// Use @WorkspaceRelation or @WorkspaceDynamicRelation for relation fields
FieldMetadataType.RELATION
>,
> = SyncableEntityOptions & {
type: T;
label: string;
description?: string;
icon?: string;
defaultValue?: FieldMetadataDefaultValue<T>;
options?: FieldMetadataOptions<T>;
settings?: FieldMetadataSettings<T>;
isNullable?: boolean;
};
import { type FieldMetadataType } from 'twenty-shared/types';
import { type FieldManifest } from 'twenty-shared/application';
export const Field = <T extends FieldMetadataType>(
_: FieldOptions<T>,
_: FieldManifest<T>,
): PropertyDecorator => {
return () => {};
};
@@ -1,28 +1,14 @@
import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type';
import {
type ServerlessFunctionManifest,
type ServerlessFunctionTriggerManifest,
} from 'twenty-shared/application';
type RouteTrigger = {
type: 'route';
path: string;
httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
isAuthRequired: boolean;
};
type CronTrigger = {
type: 'cron';
pattern: string;
};
type DatabaseEventTrigger = {
type: 'databaseEvent';
eventName: string;
};
type FunctionTrigger = SyncableEntityOptions &
(RouteTrigger | CronTrigger | DatabaseEventTrigger);
export type FunctionConfig = SyncableEntityOptions & {
export type FunctionConfig = Omit<
ServerlessFunctionManifest,
'handlerPath' | 'handlerName'
> & {
name?: string;
description?: string;
timeoutSeconds?: number;
triggers?: FunctionTrigger[];
triggers?: ServerlessFunctionTriggerManifest[];
};
@@ -26,4 +26,6 @@ export { Relation } from './fields/relation.decorator';
export type { FunctionConfig } from './function-config';
export { Object } from './objects/object.decorator';
export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './objects/standard-object-ids';
export { PermissionFlag } from './permission-flag-type';
export type { RoleConfig } from './role-config';
export type { SyncableEntityOptions } from './syncable-entity-options.type';
@@ -1,13 +1,6 @@
import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type';
import { type ObjectManifest } from 'twenty-shared/application';
type ObjectMetadataOptions = SyncableEntityOptions & {
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
};
type ObjectMetadataOptions = Omit<ObjectManifest, 'fields'>;
export const Object = (_: ObjectMetadataOptions): ClassDecorator => {
return () => {};
@@ -0,0 +1 @@
export { PermissionFlagType as PermissionFlag } from 'twenty-shared/constants';
@@ -0,0 +1,3 @@
import type { RoleManifest } from 'twenty-shared/application';
export type RoleConfig = RoleManifest;
@@ -3,6 +3,11 @@ import chalk from 'chalk';
import { join, resolve } from 'path';
import { ApiService } from '@/cli/services/api.service';
import { ConfigService } from '@/cli/services/config.service';
import * as fs from 'fs-extra';
import {
DEFAULT_API_URL_NAME,
DEFAULT_API_KEY_NAME,
} from 'twenty-shared/application';
export const GENERATED_FOLDER_NAME = 'generated';
@@ -39,23 +44,73 @@ export class GenerateService {
console.log(chalk.gray(`Output: ${outputPath}`));
const getSchemaResponse = await this.apiService.getSchema();
if (!getSchemaResponse.success) {
return;
}
const { data: schema } = getSchemaResponse;
const output = resolve(outputPath);
await generate({
schema,
output: resolve(outputPath),
output,
scalarTypes: {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
},
verbose: true,
});
await this.injectTwentyClient(output);
console.log(chalk.green('✓ Client generated successfully!'));
console.log(chalk.gray(`Generated files at: ${outputPath}`));
}
private async injectTwentyClient(output: string) {
const twentyClientContent = `
// ----------------------------------------------------
// ✨ Custom Twenty client (auto-injected)
// ----------------------------------------------------
const defaultOptions: ClientOptions = {
url: \`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\`,
headers: {
'Content-Type': 'application/json',
Authorization: \`Bearer \${process.env.${DEFAULT_API_KEY_NAME}}\`,
},
}
export default class Twenty {
private client: Client;
constructor(options?: ClientOptions) {
const merged: ClientOptions = {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...(options?.headers ?? {}),
},
};
this.client = createClient(merged);
}
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
return this.client.query(request);
}
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
return this.client.mutation(request);
}
}
`;
await fs.appendFile(join(output, 'index.ts'), twentyClientContent);
}
}
@@ -2,7 +2,7 @@ import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { loadManifest } from '@/cli/utils/load-manifest';
import { v4 } from 'uuid';
import { type ApplicationConfig } from '@/application';
const write = (root: string, file: string, content: string) => {
const abs = join(root, file);
@@ -36,7 +36,51 @@ declare module 'twenty-sdk' {
description?: string;
icon?: string;
applicationVariables?: Record<string, ApplicationVariable>;
functionRoleUniversalIdentifier?: string;
};
export type RoleConfig = SyncableEntityOptions & {
label: string;
description?: string;
icon?: string;
canReadAllObjectRecords?: boolean;
canUpdateAllObjectRecords?: boolean;
canSoftDeleteAllObjectRecords?: boolean;
canDestroyAllObjectRecords?: boolean;
objectPermissions?: any[];
fieldPermissions?: any[];
permissionFlags?: any[];
};
export enum PermissionFlag {
API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS',
WORKSPACE = 'WORKSPACE',
WORKSPACE_MEMBERS = 'WORKSPACE_MEMBERS',
ROLES = 'ROLES',
DATA_MODEL = 'DATA_MODEL',
SECURITY = 'SECURITY',
WORKFLOWS = 'WORKFLOWS',
IMPERSONATE = 'IMPERSONATE',
SSO_BYPASS = 'SSO_BYPASS',
APPLICATIONS = 'APPLICATIONS',
LAYOUTS = 'LAYOUTS',
BILLING = 'BILLING',
AI_SETTINGS = 'AI_SETTINGS',
// Tool permissions
AI = 'AI',
VIEWS = 'VIEWS',
UPLOAD_FILE = 'UPLOAD_FILE',
DOWNLOAD_FILE = 'DOWNLOAD_FILE',
SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL',
HTTP_REQUEST_TOOL = 'HTTP_REQUEST_TOOL',
IMPORT_CSV = 'IMPORT_CSV',
EXPORT_CSV = 'EXPORT_CSV',
CONNECTED_ACCOUNTS = 'CONNECTED_ACCOUNTS',
PROFILE_INFORMATION = 'PROFILE_INFORMATION',
}
type RouteTrigger = {
type: 'route';
@@ -92,6 +136,39 @@ declare module 'twenty-sdk' {
}
`;
const defaultRoleMock = `
import { PermissionFlag, type RoleConfig } from 'twenty-sdk';
export const functionRole: RoleConfig = {
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'hello-world-role',
description: 'A role to define app permissions',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
objectPermissions: [
{
objectNameSingular: 'postCard',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [
{
objectNameSingular: 'postCard',
fieldName: 'content',
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
}
`;
const serverlessFunctionMock = `
import { type FunctionConfig } from 'twenty-sdk';
@@ -273,14 +350,30 @@ const yarnLockMock = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIR
# yarn lockfile v1
`;
const applicationConfigMock = `import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: '${v4()}',
const applicationMockConfig: ApplicationConfig = {
universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe',
displayName: 'My App',
description: 'My app description',
icon: 'IconWorld',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: '3a327392-3a0f-4605-9223-0633f063eaf6',
description: 'Twenty API Key',
isSecret: true,
},
TWENTY_API_URL: {
universalIdentifier: 'aa7210a6-75b0-46ca-bcbe-09a5b42a76ec',
description: 'Twenty API Url',
isSecret: false,
},
},
functionRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
};
const applicationConfigMock = `import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = ${JSON.stringify(applicationMockConfig)};
export default config;
`;
@@ -310,6 +403,8 @@ describe('loadManifest (integration)', () => {
write(appDirectory, 'src/hello.ts', serverlessFunctionMock);
write(appDirectory, 'src/defaultRole.ts', defaultRoleMock);
write(
appDirectory,
'src/types/twenty-sdk-application.d.ts',
@@ -340,11 +435,7 @@ describe('loadManifest (integration)', () => {
);
// application
const { universalIdentifier: _, ...otherInfo } = manifest.application;
expect(otherInfo).toEqual({
displayName: 'My App',
description: 'My app description',
});
expect(manifest.application).toEqual(applicationMockConfig);
expect(manifest.objects.length).toBe(1);
@@ -456,6 +547,31 @@ describe('loadManifest (integration)', () => {
}
}
}
//Role
expect(manifest.roles).toHaveLength(1);
for (const role of manifest.roles ?? []) {
const {
universalIdentifier: _,
objectPermissions,
fieldPermissions,
permissionFlags,
...otherInfo
} = role;
expect(otherInfo).toEqual({
label: 'hello-world-role',
description: 'A role to define app permissions',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
});
expect(Array.isArray(objectPermissions)).toBe(true);
expect(Array.isArray(fieldPermissions)).toBe(true);
expect(Array.isArray(permissionFlags)).toBe(true);
}
});
it('should not define serverless for util file', async () => {
@@ -482,6 +598,7 @@ export const format = async (params: any): Promise<any> => {
]);
expect(Object.keys(manifest.sources['src'])).toEqual([
'Account.ts',
'defaultRole.ts',
'hello.ts',
]);
});
@@ -40,7 +40,7 @@ import {
type ApplicationManifest,
type ServerlessFunctionManifest,
type ObjectManifest,
type FieldManifest,
type RoleManifest,
} from 'twenty-shared/application';
import { findPathFile } from '@/cli/utils/find-path-file';
import { getTsProgramAndDiagnostics } from '@/cli/utils/get-ts-program-and-diagnostics';
@@ -185,7 +185,7 @@ const collectObjects = (program: Program) => {
}
fields.push({
...(fieldCfg as FieldManifest),
...(fieldCfg as any),
...(name ? { name } : {}),
});
}
@@ -429,7 +429,9 @@ export const extractTwentyAppConfig = (program: Program): Application => {
decl.initializer &&
isObjectLiteralExpression(decl.initializer)
) {
found = exprToValue(decl.initializer) as Application;
found = exprToValue(
decl.initializer,
) as unknown as Application;
}
}
}
@@ -485,6 +487,39 @@ const isGeneratedModuleUsedInProgram = (program: Program): boolean => {
return false;
};
export const collectRoles = (program: Program): Array<RoleManifest> => {
const roles: Array<RoleManifest> = [];
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
for (const st of sf.statements) {
if (!isVariableStatement(st)) continue;
// must be "export const ..."
const isExported =
st.modifiers?.some((m) => m.kind === SyntaxKind.ExportKeyword) ?? false;
if (!isExported) continue;
for (const decl of st.declarationList.declarations) {
if (!isIdentifier(decl.name)) continue;
// must be typed RoleConfig (matches: RoleConfig, foo.RoleConfig, import type RoleConfig, etc.)
const typeText = decl.type?.getText(sf) ?? '';
if (!typeText.includes('RoleConfig')) continue;
// must be "= { ... }"
const init = decl.initializer;
if (!init || !isObjectLiteralExpression(init)) continue;
roles.push(exprToValue(init) as unknown as RoleManifest);
}
}
}
return roles;
};
export const loadManifest = async (
appPath: string,
): Promise<{
@@ -509,10 +544,11 @@ export const loadManifest = async (
diagnostics,
});
const [objects, serverlessFunctions, application, sources] = [
const [objects, serverlessFunctions, application, roles, sources] = [
collectObjects(program),
collectServerlessFunctions(program, appPath),
extractTwentyAppConfig(program),
collectRoles(program),
await loadFolderContentIntoJson(program, appPath),
];
@@ -525,6 +561,7 @@ export const loadManifest = async (
application,
objects,
serverlessFunctions,
roles,
sources,
},
shouldGenerate,
+2
View File
@@ -1,6 +1,7 @@
import { type APP_LOCALES } from 'twenty-shared/translations';
import { type ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type User } from 'src/engine/core-modules/user/user.entity';
@@ -11,6 +12,7 @@ declare module 'express-serve-static-core' {
interface Request {
user?: User | null;
apiKey?: ApiKey | null;
application?: ApplicationEntity | null;
userWorkspace?: UserWorkspace;
locale: keyof typeof APP_LOCALES;
workspace?: Workspace;
-3
View File
@@ -15,7 +15,6 @@ declare module '@jest/types' {
INVALID_ACCESS_TOKEN: string;
APPLE_JONY_MEMBER_ACCESS_TOKEN: string;
APPLE_PHIL_GUEST_ACCESS_TOKEN: string;
ACME_JONY_MEMBER_ACCESS_TOKEN: string;
APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN: string;
API_KEY_ACCESS_TOKEN: string;
testDataSource?: DataSource;
@@ -32,8 +31,6 @@ declare global {
const APPLE_PHIL_GUEST_ACCESS_TOKEN: string;
const API_KEY_ACCESS_TOKEN: string;
const APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN: string;
const ACME_JONY_MEMBER_ACCESS_TOKEN: string;
const WORKSPACE_AGNOSTIC_TOKEN: string;
// Additional global properties set during test setup
var testDataSource: DataSource;
@@ -82,15 +82,13 @@ const jestConfig: JestConfigWithTsJest = {
APPLE_JANE_ADMIN_ACCESS_TOKEN:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
EXPIRED_ACCESS_TOKEN:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzM4MzIzODc5LCJleHAiOjE3MzgzMjU2Nzl9.m73hHVpnw5uGNGrSuKxn6XtKEUK3Wqkp4HsQdYfZiHo',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaXNJbXBlcnNvbmF0aW5nIjpmYWxzZSwiaWF0IjoxNzY1NDgwNDkzLCJleHAiOjE3NjU0ODA1MDN9.H0rNZyYvaWsDqim8U0-knIpq-29EQ6ox9Eag4WpwZg8',
INVALID_ACCESS_TOKEN:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzM4MzIzODc5LCJleHAiOjE3MzgzMjU2Nzl9.m73hHVpnw5uGNGrSuKxn6XtKEUK3Wqkp4HsQdYfZiHp',
APPLE_JONY_MEMBER_ACCESS_TOKEN:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNzdkNS00Y2I2LWI2MGEtZjRhODM1YTg1ZDYxIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMzk1Ny00OTA4LTljMzYtMjkyOWEyM2Y4MzUzIiwiaWF0IjoxNzM5NDU5NTcwLCJleHAiOjMzMjk3MDU5NTcwfQ.Er7EEU4IP4YlGN79jCLR_6sUBqBfKx2M3G_qGiDpPRo',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ1c2VySWQiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNzdkNS00Y2I2LWI2MGEtZjRhODM1YTg1ZDYxIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMzk1Ny00OTA4LTljMzYtMjkyOWEyM2Y4MzUzIiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaXNJbXBlcnNvbmF0aW5nIjpmYWxzZSwiaWF0IjoxNzY1NDY5OTgzLCJleHAiOjI3MTIxOTc5ODN9.B55MfSd3LShO9_61nvKHUzsSJD6XszEbFGn_76VpaKs',
APPLE_PHIL_GUEST_ACCESS_TOKEN:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC03MTY5LTQyY2YtYmM0Ny0xY2ZlZjE1MjY0YjgiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMTU1My00NWM2LWEwMjgtNWE5MDY0Y2NlMDdmIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtNzE2OS00MmNmLWJjNDctMWNmZWYxNTI2NGIxIiwiaWF0IjoxNzM5ODg4NDcwLCJleHAiOjMzMjk3NDg4NDcwfQ.0NEu-AWGv3l77rs-56Z5Gt0UTU7HDl6qUTHUcMWNrCc',
ACME_JONY_MEMBER_ACCESS_TOKEN:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ1c2VySWQiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ3b3Jrc3BhY2VJZCI6IjNiOGU2NDU4LTVmYzEtNGU2My04NTYzLTAwOGNjZGRhYTZkYiIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNzdkNS00Y2I2LWI2MGEtZjRhODM1YTg1ZDYxIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtZTEwYS00YzI3LWE5MGItYjA4YzU3YjAyZDQ1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUyMDc4MDA0LCJleHAiOjMzMzA5Njc4MDA0fQ.JBtQCkNWsqAkzouxhcVjCEikV6A_-qr3IflE67NYQYY',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC03MTY5LTQyY2YtYmM0Ny0xY2ZlZjE1MjY0YjgiLCJ1c2VySWQiOiIyMDIwMjAyMC03MTY5LTQyY2YtYmM0Ny0xY2ZlZjE1MjY0YjgiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMTU1My00NWM2LWEwMjgtNWE5MDY0Y2NlMDdmIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtNzE2OS00MmNmLWJjNDctMWNmZWYxNTI2NGIxIiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaXNJbXBlcnNvbmF0aW5nIjpmYWxzZSwiaWF0IjoxNzY1NDcwOTczLCJleHAiOjI3MTIxOTg5NzN9.cd3CmyWiwDJEWD3VgVqG0JfXQ9w21y2eWx67vGPH9SI',
API_KEY_ACCESS_TOKEN:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzQ0OTgzNzUwLCJleHAiOjQ4OTg1ODM2OTMsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.4xkkwz_uu2xzs_V8hJSaM15fGziT5zS3vq2lM48OHr0',
APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN:
@@ -0,0 +1,93 @@
import { Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { DataSource, Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
@Command({
name: 'upgrade:1-14:udpate-created-by-enum',
description: 'Add new APPLICATION value to createdBy enum',
})
export class UpdateCreatedByEnumCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(UpdateCreatedByEnumCommand.name);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
dataSource,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun || false;
if (!isDefined(dataSource)) {
throw new Error(
`Could not find data source for workspace ${workspaceId}, should never occur`,
);
}
const schemaName = getWorkspaceSchemaName(workspaceId);
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
const createdByEnums = await queryRunner.query(
`SELECT t.typname
FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typtype = 'e'
AND n.nspname = '${schemaName}'
AND t.typname LIKE '%\\_createdBySource\\_enum' ESCAPE '\\'
AND NOT EXISTS (
SELECT 1
FROM pg_enum e
WHERE e.enumtypid = t.oid
AND e.enumlabel = 'APPLICATION'
);`,
);
if (isDryRun) {
this.logger.log(
`Dry run mode: found ${createdByEnums.length} createdBy enums to update`,
);
}
if (!options.dryRun) {
this.logger.log(`Updating ${createdByEnums.length} createdBy enums`);
try {
for (const createdByEnum of createdByEnums) {
await queryRunner.query(
`ALTER TYPE "${schemaName}"."${createdByEnum.typname}" ADD VALUE 'APPLICATION'`,
);
}
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.log(
`Rollbacking UpdateCreatedByEnumCommand: ${error.message}`,
);
} finally {
await queryRunner.release();
}
}
}
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UpdateCreatedByEnumCommand } from 'src/database/commands/upgrade-version-command/1-14/1-14-update-created-by-enum.command';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
@Module({
imports: [
TypeOrmModule.forFeature([WorkspaceEntity]),
DataSourceModule,
WorkspaceSchemaManagerModule,
],
providers: [UpdateCreatedByEnumCommand],
exports: [UpdateCreatedByEnumCommand],
})
export class V1_14_UpgradeVersionCommandModule {}
@@ -5,11 +5,13 @@ import { V1_13_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { V1_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-14/1-14-upgrade-version-command.module';
@Module({
imports: [
TypeOrmModule.forFeature([WorkspaceEntity]),
V1_13_UpgradeVersionCommandModule,
V1_14_UpgradeVersionCommandModule,
DataSourceModule,
],
providers: [UpgradeCommand],
@@ -21,6 +21,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { UpdateCreatedByEnumCommand } from 'src/database/commands/upgrade-version-command/1-14/1-14-update-created-by-enum.command';
@Command({
name: 'upgrade',
@@ -45,6 +46,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly backfillViewMainGroupByFieldMetadataIdCommand: BackfillViewMainGroupByFieldMetadataIdCommand,
protected readonly cleanEmptyStringNullInTextFieldsCommand: CleanEmptyStringNullInTextFieldsCommand,
protected readonly renameIndexNameCommand: RenameIndexNameCommand,
// 1.14 Commands
protected readonly updateCreatedByEnumCommand: UpdateCreatedByEnumCommand,
) {
super(
workspaceRepository,
@@ -67,9 +71,12 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.renameIndexNameCommand,
];
const commands_1140: VersionCommands = [this.updateCreatedByEnumCommand];
this.allCommands = {
'1.12.0': commands_1120,
'1.13.0': commands_1130,
'1.14.0': commands_1140,
};
}
@@ -0,0 +1,23 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class UpdateRoleColumns1765206100942 implements MigrationInterface {
name = 'UpdateRoleColumns1765206100942';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."roleTarget" DROP COLUMN "targetApplicationId"`,
);
await queryRunner.query(
`ALTER TABLE "core"."application" ADD "defaultServerlessFunctionRoleId" uuid`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."application" DROP COLUMN "defaultServerlessFunctionRoleId"`,
);
await queryRunner.query(
`ALTER TABLE "core"."roleTarget" ADD "targetApplicationId" uuid`,
);
}
}
@@ -49,6 +49,7 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import type { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@Injectable()
export abstract class CommonBaseQueryRunnerService<
@@ -304,6 +305,10 @@ export abstract class CommonBaseQueryRunnerService<
);
}
if (isDefined(authContext.application?.defaultServerlessFunctionRoleId)) {
return authContext.application?.defaultServerlessFunctionRoleId;
}
if (!isDefined(authContext.userWorkspaceId)) {
throw new CommonQueryRunnerException(
'Invalid auth context',
@@ -325,7 +330,9 @@ export abstract class CommonBaseQueryRunnerService<
const roleId = await this.getRoleIdOrThrow(authContext, workspaceId);
const rolePermissionConfig = { unionOf: [roleId] };
const rolePermissionConfig: RolePermissionConfig = {
intersectionOf: [roleId],
};
const repository = await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
@@ -2,6 +2,7 @@ import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-contex
interface AuthContextWithDefinedWorkspaceProperties {
user: AuthContext['user'];
application: AuthContext['application'];
workspace: NonNullable<AuthContext['workspace']>;
workspaceMetadataVersion?: string;
workspaceMemberId: AuthContext['workspaceMemberId'];
@@ -17,5 +18,9 @@ interface UserWorkspaceAuthContext extends Request {
userWorkspaceId: NonNullable<AuthContext['userWorkspaceId']>;
}
interface ApplicationAuthContext extends Request {
application: NonNullable<AuthContext['application']>;
}
export type WorkspaceAuthContext = AuthContextWithDefinedWorkspaceProperties &
(ApiKeyAuthContext | UserWorkspaceAuthContext);
(ApiKeyAuthContext | UserWorkspaceAuthContext | ApplicationAuthContext);
@@ -9,6 +9,8 @@ export const isWorkspaceAuthContext = (
): context is WorkspaceAuthContext => {
return (
isDefined(context.workspace) &&
(isDefined(context.userWorkspaceId) || isDefined(context.apiKey))
(isDefined(context.userWorkspaceId) ||
isDefined(context.apiKey) ||
isDefined(context.application))
);
};
@@ -115,6 +115,7 @@ export class GraphQLConfigService
user,
workspace,
apiKey,
application,
workspaceMemberId,
userWorkspaceId,
} = context.req;
@@ -127,6 +128,7 @@ export class GraphQLConfigService
user,
workspace,
apiKey,
application,
workspaceMemberId,
userWorkspaceId,
});
@@ -76,10 +76,10 @@ export class RestApiMetadataService {
}
private getRequestContext(request: Request): RequestContext {
const baseUrl = getServerUrl(
this.twentyConfigService.get('SERVER_URL'),
`${request.protocol}://${request.get('host')}`,
);
const baseUrl = getServerUrl({
serverUrlEnv: this.twentyConfigService.get('SERVER_URL'),
serverUrlFallback: `${request.protocol}://${request.get('host')}`,
});
return {
body: request.body,
@@ -14,6 +14,7 @@ import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-module
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { buildCreatedByFromApplication } from 'src/engine/core-modules/actor/utils/build-created-by-from-application.util';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CreateInput = Record<string, any>;
@@ -106,7 +107,7 @@ export class CreatedByFromAuthContextService {
private async buildCreatedBy(
authContext: AuthContext,
): Promise<ActorMetadata> {
const { workspace, user, apiKey } = authContext;
const { workspace, user, apiKey, application } = authContext;
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
@@ -143,6 +144,12 @@ export class CreatedByFromAuthContextService {
});
}
if (isDefined(application)) {
return buildCreatedByFromApplication({
application,
});
}
throw new Error(
'Unable to build createdBy metadata - no valid actor information found in auth context',
);
@@ -0,0 +1,15 @@
import { type ActorMetadata, FieldActorSource } from 'twenty-shared/types';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
type BuildCreatedByFromApplicationArgs = {
application: ApplicationEntity;
};
export const buildCreatedByFromApplication = ({
application,
}: BuildCreatedByFromApplicationArgs): ActorMetadata => ({
source: FieldActorSource.APPLICATION,
name: application.name,
workspaceMemberId: null,
context: {},
});
@@ -389,7 +389,7 @@ describe('ApiKeyService', () => {
);
expect(mockJwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
JwtTokenTypeEnum.ACCESS,
JwtTokenTypeEnum.API_KEY,
mockWorkspaceId,
);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
@@ -143,7 +143,7 @@ export class ApiKeyService {
await this.validateApiKey(apiKeyId, workspaceId);
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.ACCESS,
JwtTokenTypeEnum.API_KEY,
workspaceId,
);
@@ -16,10 +16,10 @@ import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serve
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module';
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
@Module({
imports: [
@@ -37,9 +37,9 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
WorkspaceMigrationV2Module,
PermissionsModule,
RoleModule,
RoleTargetModule,
ObjectPermissionModule,
PermissionFlagModule,
WorkflowCommonModule,
],
providers: [
ApplicationResolver,
@@ -3,11 +3,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { parse } from 'path';
import { isDefined } from 'twenty-shared/utils';
import { Sources } from 'twenty-shared/types';
import { HTTPMethod, Sources } from 'twenty-shared/types';
import {
ApplicationManifest,
FieldManifest,
ObjectManifest,
RoleManifest,
ServerlessFunctionManifest,
ServerlessFunctionTriggerManifest,
} from 'twenty-shared/application';
@@ -39,10 +40,10 @@ import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-f
import { computeMetadataNameFromLabelOrThrow } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label-or-throw.util';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service';
import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service';
import { PermissionFlagService } from 'src/engine/metadata-modules/permission-flag/permission-flag.service';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service';
@Injectable()
export class ApplicationSyncService {
@@ -62,7 +63,6 @@ export class ApplicationSyncService {
private readonly routeTriggerV2Service: RouteTriggerV2Service,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly roleService: RoleService,
private readonly roleTargetService: RoleTargetService,
private readonly objectPermissionService: ObjectPermissionService,
private readonly fieldPermissionService: FieldPermissionService,
private readonly permissionService: PermissionFlagService,
@@ -106,6 +106,12 @@ export class ApplicationSyncService {
});
}
await this.syncRoles({
manifest,
workspaceId,
applicationId: application.id,
});
this.logger.log('✅ Application sync from manifest completed');
}
@@ -130,17 +136,12 @@ export class ApplicationSyncService {
version: packageJson.version,
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
serverlessFunctionLayerId: null,
defaultServerlessFunctionRoleId: null,
workspaceId,
}));
let serverlessFunctionLayerId = application.serverlessFunctionLayerId;
await this.syncApplicationRole({
applicationId: application.id,
manifest,
workspaceId,
});
if (manifest.serverlessFunctions.length > 0) {
if (!isDefined(serverlessFunctionLayerId)) {
serverlessFunctionLayerId = (
@@ -175,114 +176,177 @@ export class ApplicationSyncService {
description: manifest.application.description,
version: packageJson.version,
serverlessFunctionLayerId,
defaultServerlessFunctionRoleId: null,
});
}
private async syncApplicationRole({
private async syncRoles({
manifest,
applicationId,
workspaceId,
applicationId,
}: {
manifest: ApplicationManifest;
applicationId: string;
workspaceId: string;
applicationId: string;
}) {
const applicationRole = manifest.application.applicationRole;
let defaultServerlessFunctionRoleId: string | null = null;
if (
isDefined(applicationRole) &&
isDefined(applicationRole.universalIdentifier)
) {
let role = await this.roleService.getRoleByUniversalIdentifier({
universalIdentifier: applicationRole.universalIdentifier,
for (const role of manifest.roles ?? []) {
let existingRole = await this.roleService.getRoleByUniversalIdentifier({
universalIdentifier: role.universalIdentifier,
workspaceId,
});
if (role) {
if (existingRole) {
await this.roleService.updateRole({
input: {
id: role.id,
update: {
...applicationRole,
canAccessAllTools: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
canBeAssignedToApplications: true,
},
id: existingRole.id,
update: role,
},
workspaceId,
});
} else {
role = await this.roleService.createRole({
input: {
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
...applicationRole,
canAccessAllTools: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
canBeAssignedToApplications: true,
},
workspaceId,
applicationId: applicationId,
});
await this.roleTargetService.create({
createRoleTargetInput: {
roleId: role.id,
applicationId: applicationId,
universalIdentifier: role.universalIdentifier,
targetMetadataForeignKey: 'targetApplicationId',
targetId: applicationId,
},
existingRole = await this.roleService.createRole({
input: role,
workspaceId,
applicationId,
});
}
await this.syncApplicationRolePermissions({
role,
workspaceId,
roleId: existingRole.id,
});
if (
isDefined(applicationRole.objectPermissions) &&
applicationRole.objectPermissions.length > 0
existingRole.universalIdentifier ===
manifest.application.functionRoleUniversalIdentifier
) {
defaultServerlessFunctionRoleId = existingRole.id;
}
}
if (isDefined(defaultServerlessFunctionRoleId)) {
await this.applicationService.update(applicationId, {
defaultServerlessFunctionRoleId: defaultServerlessFunctionRoleId,
});
}
}
private async syncApplicationRolePermissions({
role,
workspaceId,
roleId,
}: {
role: RoleManifest;
workspaceId: string;
roleId: string;
}) {
if (
(role.objectPermissions ?? []).length > 0 ||
(role.fieldPermissions ?? []).length > 0
) {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const { idByNameSingular: objectIdByNameSingular } =
buildObjectIdByNameMaps(flatObjectMetadataMaps);
const formattedObjectPermissions = role.objectPermissions
?.map((perm) => ({
...perm,
objectMetadataId: isDefined(perm.objectNameSingular)
? objectIdByNameSingular[perm.objectNameSingular]
: isDefined(perm.objectUniversalIdentifier)
? flatObjectMetadataMaps.idByUniversalIdentifier[
perm.objectUniversalIdentifier
]
: undefined,
}))
.filter((perm): perm is typeof perm & { objectMetadataId: string } =>
isDefined(perm.objectMetadataId),
);
if (isDefined(formattedObjectPermissions)) {
await this.objectPermissionService.upsertObjectPermissions({
workspaceId,
input: {
roleId: role.id,
objectPermissions: applicationRole.objectPermissions,
roleId,
objectPermissions: formattedObjectPermissions,
},
});
}
if (
isDefined(applicationRole.fieldPermissions) &&
applicationRole.fieldPermissions.length > 0
) {
const formattedFieldPermissions = role?.fieldPermissions
?.map((perm) => {
const objectMetadataId = isDefined(perm.objectNameSingular)
? objectIdByNameSingular[perm.objectNameSingular]
: isDefined(perm.objectUniversalIdentifier)
? flatObjectMetadataMaps.idByUniversalIdentifier[
perm.objectUniversalIdentifier
]
: undefined;
const fieldMetadataId = isDefined(objectMetadataId)
? isDefined(perm.fieldName)
? Object.values(flatFieldMetadataMaps.byId).find(
(flatField) =>
isDefined(flatField) &&
flatField.objectMetadataId === objectMetadataId &&
flatField.name === perm.fieldName,
)?.id
: isDefined(perm.fieldUniversalIdentifier)
? Object.values(flatFieldMetadataMaps.byId).find(
(flatField) =>
isDefined(flatField) &&
flatField.objectMetadataId === objectMetadataId &&
flatField.universalIdentifier ===
perm.fieldUniversalIdentifier,
)?.id
: undefined
: undefined;
return {
...perm,
objectMetadataId,
fieldMetadataId,
};
})
.filter(
(
perm,
): perm is typeof perm & {
objectMetadataId: string;
fieldMetadataId: string;
} =>
isDefined(perm.objectMetadataId) && isDefined(perm.fieldMetadataId),
);
if (isDefined(formattedFieldPermissions)) {
await this.fieldPermissionService.upsertFieldPermissions({
workspaceId,
input: {
roleId: role.id,
fieldPermissions: applicationRole.fieldPermissions,
roleId,
fieldPermissions: formattedFieldPermissions,
},
});
}
}
if (
isDefined(applicationRole.permissionFlags) &&
applicationRole.permissionFlags.length > 0
) {
await this.permissionService.upsertPermissionFlags({
workspaceId,
input: {
roleId: role.id,
permissionFlagKeys: applicationRole.permissionFlags,
},
});
}
if (isDefined(role?.permissionFlags) && role.permissionFlags.length > 0) {
await this.permissionService.upsertPermissionFlags({
workspaceId,
input: {
roleId,
permissionFlagKeys: role.permissionFlags,
},
});
}
}
@@ -1036,7 +1100,7 @@ export class ApplicationSyncService {
id: triggerToUpdate.id,
update: {
path: triggerToSync.path,
httpMethod: triggerToSync.httpMethod,
httpMethod: triggerToSync.httpMethod as HTTPMethod,
isAuthRequired: triggerToSync.isAuthRequired,
},
};
@@ -1054,7 +1118,7 @@ export class ApplicationSyncService {
const createRouteTriggerInput = {
path: triggerToCreate.path,
httpMethod: triggerToCreate.httpMethod,
httpMethod: triggerToCreate.httpMethod as HTTPMethod,
isAuthRequired: triggerToCreate.isAuthRequired,
serverlessFunctionId,
};
@@ -1,4 +1,4 @@
import { ObjectType } from '@nestjs/graphql';
import { Field, ObjectType } from '@nestjs/graphql';
import {
Column,
@@ -14,6 +14,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
@@ -60,6 +61,12 @@ export class ApplicationEntity {
@Column({ nullable: true, type: 'uuid' })
serverlessFunctionLayerId: string | null;
@Column({ nullable: true, type: 'uuid' })
defaultServerlessFunctionRoleId: string | null;
@Field(() => RoleDTO, { nullable: true })
defaultServerlessFunctionRole: RoleDTO | null;
@Column({ nullable: false, type: 'boolean', default: true })
canBeUninstalled: boolean;
@@ -13,6 +13,7 @@ import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicatio
import { AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
@ObjectType('Application')
export class ApplicationDTO {
@@ -43,6 +44,15 @@ export class ApplicationDTO {
@IsBoolean()
canBeUninstalled: boolean;
@IsOptional()
@IsString()
@Field({ nullable: true })
defaultServerlessFunctionRoleId?: string;
@IsOptional()
@Field(() => RoleDTO, { nullable: true })
defaultServerlessFunctionRole?: RoleDTO;
@Field(() => [AgentDTO])
agents?: AgentDTO[];
@@ -13,6 +13,7 @@ export const AuthExceptionCode = appendCommonExceptionCode({
EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
CLIENT_NOT_FOUND: 'CLIENT_NOT_FOUND',
WORKSPACE_NOT_FOUND: 'WORKSPACE_NOT_FOUND',
APPLICATION_NOT_FOUND: 'APPLICATION_NOT_FOUND',
INVALID_INPUT: 'INVALID_INPUT',
FORBIDDEN_EXCEPTION: 'FORBIDDEN_EXCEPTION',
INSUFFICIENT_SCOPES: 'INSUFFICIENT_SCOPES',
@@ -63,6 +63,7 @@ import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/se
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { TwoFactorAuthenticationMethodEntity } from '../two-factor-authentication/entities/two-factor-authentication-method.entity';
import { TwoFactorAuthenticationModule } from '../two-factor-authentication/two-factor-authentication.module';
@@ -87,6 +88,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
UserEntity,
AppTokenEntity,
ApiKeyEntity,
ApplicationEntity,
FeatureFlagEntity,
WorkspaceSSOIdentityProviderEntity,
KeyValuePairEntity,
@@ -4,7 +4,10 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
import {
type JwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy';
@@ -24,6 +27,7 @@ describe('JwtAuthStrategy', () => {
let userWorkspaceRepository: any;
let userRepository: any;
let apiKeyRepository: any;
let applicationRepository: any;
let jwtWrapperService: any;
let permissionsService: any;
@@ -49,6 +53,10 @@ describe('JwtAuthStrategy', () => {
findOne: jest.fn(),
};
applicationRepository = {
findOne: jest.fn(),
};
jwtWrapperService = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
@@ -66,7 +74,7 @@ describe('JwtAuthStrategy', () => {
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
workspaceRepository.findOneBy.mockResolvedValue(null);
@@ -74,6 +82,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -91,7 +100,7 @@ describe('JwtAuthStrategy', () => {
it('should throw AuthExceptionCode if type is API_KEY not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
const mockWorkspace = new WorkspaceEntity();
@@ -104,6 +113,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -121,7 +131,7 @@ describe('JwtAuthStrategy', () => {
it('should throw AuthExceptionCode if API_KEY is revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
const mockWorkspace = new WorkspaceEntity();
@@ -137,6 +147,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -154,7 +165,7 @@ describe('JwtAuthStrategy', () => {
it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
const mockWorkspace = new WorkspaceEntity();
@@ -170,6 +181,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -198,7 +210,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
@@ -210,6 +222,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -234,7 +247,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
@@ -248,6 +261,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -272,7 +286,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
@@ -290,6 +304,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -303,6 +318,44 @@ describe('JwtAuthStrategy', () => {
});
});
describe('APPLICATION token validation', () => {
it('should throw AuthExceptionCode if type is APPLICATION, and application not found', async () => {
const validApplicationId = randomUUID();
const validWorkspaceId = randomUUID();
const payload = {
sub: validApplicationId,
type: JwtTokenTypeEnum.APPLICATION,
applicationId: validApplicationId,
workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity());
applicationRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('Application not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.APPLICATION_NOT_FOUND);
}
});
});
describe('Impersonation validation', () => {
it('should throw AuthException if impersonation token has missing impersonatorUserWorkspaceId', async () => {
const validUserId = randomUUID();
@@ -311,7 +364,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -335,6 +388,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -357,7 +411,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -380,6 +434,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -401,7 +456,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -427,6 +482,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -449,7 +505,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -485,6 +541,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -507,7 +564,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -537,6 +594,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -560,7 +618,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -607,6 +665,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -629,7 +688,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -676,6 +735,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -699,7 +759,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -746,6 +806,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -768,7 +829,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -809,6 +870,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -837,7 +899,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -874,6 +936,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -16,9 +16,11 @@ import {
import {
type AccessTokenJwtPayload,
type ApiKeyTokenJwtPayload,
ApplicationTokenJwtPayload,
type AuthContext,
type FileTokenJwtPayload,
type JwtPayload,
JwtTokenTypeEnum,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@@ -27,12 +29,16 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(UserWorkspaceEntity)
@@ -52,7 +58,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
>(rawJwtToken);
const appSecretBody =
decodedToken.type === 'WORKSPACE_AGNOSTIC'
decodedToken.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
? decodedToken.userId
: decodedToken.workspaceId;
@@ -270,7 +276,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
private async validateWorkspaceAgnosticToken(
payload: WorkspaceAgnosticTokenJwtPayload,
) {
): Promise<AuthContext> {
const user = await this.userRepository.findOne({
where: { id: payload.sub },
});
@@ -283,6 +289,39 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
return { user, authProvider: payload.authProvider };
}
private async validateApplicationToken(
payload: ApplicationTokenJwtPayload,
): Promise<AuthContext> {
const workspace = await this.workspaceRepository.findOneBy({
id: payload.workspaceId,
});
if (!isDefined(workspace)) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
const applicationId = payload.sub ?? payload.applicationId;
const application = await this.applicationRepository.findOne({
where: { id: applicationId },
});
if (!isDefined(application)) {
throw new AuthException(
'Application not found',
AuthExceptionCode.APPLICATION_NOT_FOUND,
);
}
return {
application,
workspace,
};
}
private isLegacyApiKeyPayload(
payload: JwtPayload,
): payload is ApiKeyTokenJwtPayload {
@@ -291,19 +330,25 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
async validate(payload: JwtPayload): Promise<AuthContext> {
// Support legacy api keys
if (payload.type === 'API_KEY' || this.isLegacyApiKeyPayload(payload)) {
if (
payload.type === JwtTokenTypeEnum.API_KEY ||
this.isLegacyApiKeyPayload(payload)
) {
return await this.validateAPIKey(payload);
}
if (payload.type === 'WORKSPACE_AGNOSTIC') {
if (payload.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC) {
return await this.validateWorkspaceAgnosticToken(payload);
}
// `!payload.type` is here to support legacy token
if (payload.type === 'ACCESS' || !payload.type) {
if (payload.type === JwtTokenTypeEnum.ACCESS) {
return await this.validateAccessToken(payload);
}
if (payload.type === JwtTokenTypeEnum.APPLICATION) {
return await this.validateApplicationToken(payload);
}
throw new AuthException(
'Invalid token',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
@@ -265,10 +265,7 @@ describe('AccessTokenService', () => {
const result = await service.validateTokenByRequest(mockRequest);
expect(result).toEqual(mockAuthContext);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'ACCESS',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
expect(service['jwtStrategy'].validate).toHaveBeenCalledWith(
mockDecodedToken,
@@ -154,7 +154,7 @@ export class AccessTokenService {
}
async validateToken(token: string): Promise<AuthContext> {
await this.jwtWrapperService.verifyJwtToken(token, JwtTokenTypeEnum.ACCESS);
await this.jwtWrapperService.verifyJwtToken(token);
const decoded = this.jwtWrapperService.decode<AccessTokenJwtPayload>(token);
@@ -0,0 +1,159 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationException } from 'src/engine/core-modules/application/application.exception';
import { WorkspaceException } from 'src/engine/core-modules/workspace/workspace.exception';
describe('ApplicationTokenService', () => {
let service: ApplicationTokenService;
let jwtWrapperService: JwtWrapperService;
let workspaceRepository: Repository<WorkspaceEntity>;
let applicationRepository: Repository<ApplicationEntity>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationTokenService,
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn(),
extractJwtFromRequest: jest.fn(),
},
},
{
provide: getRepositoryToken(ApplicationEntity),
useClass: Repository,
},
{
provide: getRepositoryToken(WorkspaceEntity),
useClass: Repository,
},
],
}).compile();
service = module.get<ApplicationTokenService>(ApplicationTokenService);
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
applicationRepository = module.get<Repository<ApplicationEntity>>(
getRepositoryToken(ApplicationEntity),
);
workspaceRepository = module.get<Repository<WorkspaceEntity>>(
getRepositoryToken(WorkspaceEntity),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generateApplicationToken', () => {
it('should generate an application token successfully', async () => {
const workspaceId = 'workspace-id';
const applicationId = 'application-id';
const mockWorkspace = { id: workspaceId };
const mockApplication = { id: applicationId };
const mockToken = 'mock-token';
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateApplicationToken({
workspaceId,
applicationId,
expiresInSeconds: 10,
});
expect(result).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect.objectContaining({
sub: applicationId,
applicationId,
}),
expect.any(Object),
);
});
it('should handle missing userId successfully', async () => {
const workspaceId = 'workspace-id';
const applicationId = 'application-id';
const mockWorkspace = { id: workspaceId };
const mockApplication = { id: applicationId };
const mockToken = 'mock-token';
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateApplicationToken({
workspaceId,
applicationId,
expiresInSeconds: 10,
});
expect(result).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect.objectContaining({
sub: applicationId,
applicationId,
workspaceId: workspaceId,
}),
expect.any(Object),
);
});
});
it('should throw an error if application is not found', async () => {
const workspaceId = 'workspace-id';
const mockWorkspace = { id: workspaceId };
jest.spyOn(applicationRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
await expect(
service.generateApplicationToken({
applicationId: 'non-existent-application',
workspaceId: 'workspace-id',
expiresInSeconds: 10,
}),
).rejects.toThrow(ApplicationException);
});
it('should throw an error if workspace is not found', async () => {
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
await expect(
service.generateApplicationToken({
applicationId: 'application-id',
workspaceId: 'non-existent-workspace',
expiresInSeconds: 10,
}),
).rejects.toThrow(WorkspaceException);
});
});
@@ -0,0 +1,80 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { addMilliseconds } from 'date-fns';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import ms from 'ms';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import {
ApplicationTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
@Injectable()
export class ApplicationTokenService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async generateApplicationToken({
workspaceId,
applicationId,
expiresInSeconds,
}: Omit<ApplicationTokenJwtPayload, 'type' | 'sub'> & {
expiresInSeconds: number;
}): Promise<AuthToken> {
const expiresIn = `${expiresInSeconds}s`;
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
const application = await this.applicationRepository.findOne({
where: { id: applicationId, workspaceId },
});
assertIsDefinedOrThrow(
application,
new ApplicationException(
'Application not found',
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
),
);
const jwtPayload: ApplicationTokenJwtPayload = {
sub: applicationId,
applicationId,
workspaceId,
type: JwtTokenTypeEnum.APPLICATION,
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret: this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.APPLICATION,
workspaceId,
),
expiresIn,
}),
expiresAt,
};
}
}
@@ -3,6 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { LoginTokenService } from './login-token.service';
@@ -67,7 +68,7 @@ describe('LoginTokenService', () => {
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
'LOGIN',
JwtTokenTypeEnum.LOGIN,
workspaceId,
);
expect(twentyConfigService.get).toHaveBeenCalledWith(
@@ -77,7 +78,7 @@ describe('LoginTokenService', () => {
{
sub: email,
workspaceId,
type: 'LOGIN',
type: JwtTokenTypeEnum.LOGIN,
authProvider: AuthProviderEnum.Password,
impersonatorUserId: undefined,
},
@@ -112,14 +113,14 @@ describe('LoginTokenService', () => {
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
'LOGIN',
JwtTokenTypeEnum.LOGIN,
workspaceId,
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{
sub: email,
workspaceId,
type: 'LOGIN',
type: JwtTokenTypeEnum.LOGIN,
authProvider: AuthProviderEnum.Impersonation,
impersonatorUserWorkspaceId,
},
@@ -143,10 +144,7 @@ describe('LoginTokenService', () => {
const result = await service.verifyLoginToken(mockToken);
expect(result).toEqual({ sub: mockEmail });
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'LOGIN',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, {
json: true,
});
@@ -52,10 +52,7 @@ export class LoginTokenService {
}
async verifyLoginToken(loginToken: string): Promise<LoginTokenJwtPayload> {
await this.jwtWrapperService.verifyJwtToken(
loginToken,
JwtTokenTypeEnum.LOGIN,
);
await this.jwtWrapperService.verifyJwtToken(loginToken);
return this.jwtWrapperService.decode(loginToken, {
json: true,
@@ -100,10 +100,7 @@ describe('RefreshTokenService', () => {
const result = await service.verifyRefreshToken(mockToken);
expect(result).toEqual({ user: mockUser, token: mockAppToken });
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'REFRESH',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
});
it('should throw an error if the token is malformed', async () => {
@@ -154,9 +151,9 @@ describe('RefreshTokenService', () => {
{
sub: userId,
workspaceId,
type: 'REFRESH',
type: JwtTokenTypeEnum.REFRESH,
userId: 'user-id',
targetedTokenType: 'ACCESS',
targetedTokenType: JwtTokenTypeEnum.ACCESS,
},
expect.objectContaining({
secret: 'mock-secret',
@@ -190,8 +187,8 @@ describe('RefreshTokenService', () => {
(jwtWrapperService.decode as jest.Mock).mockReturnValue({
sub: userId,
jti: tokenId,
type: 'REFRESH',
targetedTokenType: 'ACCESS',
type: JwtTokenTypeEnum.REFRESH,
targetedTokenType: JwtTokenTypeEnum.ACCESS,
isImpersonating: true,
impersonatorUserWorkspaceId: 'uw-imp',
impersonatedUserWorkspaceId: 'uw-orig',
@@ -36,10 +36,7 @@ export class RefreshTokenService {
async verifyRefreshToken(refreshToken: string) {
const coolDown = this.twentyConfigService.get('REFRESH_TOKEN_COOL_DOWN');
await this.jwtWrapperService.verifyJwtToken(
refreshToken,
JwtTokenTypeEnum.REFRESH,
);
await this.jwtWrapperService.verifyJwtToken(refreshToken);
const jwtPayload =
this.jwtWrapperService.decode<RefreshTokenJwtPayload>(refreshToken);
@@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { TransientTokenService } from './transient-token.service';
@@ -72,7 +73,7 @@ describe('TransientTokenService', () => {
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{
sub: workspaceMemberId,
type: 'LOGIN',
type: JwtTokenTypeEnum.LOGIN,
userId,
workspaceId,
workspaceMemberId,
@@ -108,10 +109,7 @@ describe('TransientTokenService', () => {
userId: mockPayload.userId,
workspaceId: mockPayload.workspaceId,
});
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'LOGIN',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
});
@@ -53,10 +53,7 @@ export class TransientTokenService {
async verifyTransientToken(
transientToken: string,
): Promise<Omit<TransientTokenJwtPayload, 'type' | 'sub'>> {
await this.jwtWrapperService.verifyJwtToken(
transientToken,
JwtTokenTypeEnum.LOGIN,
);
await this.jwtWrapperService.verifyJwtToken(transientToken);
const { type: _type, ...payload } =
this.jwtWrapperService.decode<TransientTokenJwtPayload>(transientToken);
@@ -9,6 +9,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
describe('WorkspaceAgnosticToken', () => {
let service: WorkspaceAgnosticTokenService;
@@ -95,7 +96,7 @@ describe('WorkspaceAgnosticToken', () => {
authProvider: AuthProviderEnum.Password,
sub: userId,
userId: userId,
type: 'WORKSPACE_AGNOSTIC',
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
},
expect.objectContaining({
secret: 'mocked-secret',
@@ -132,7 +133,7 @@ describe('WorkspaceAgnosticToken', () => {
const mockPayload = {
sub: userId,
userId: userId,
type: 'WORKSPACE_AGNOSTIC',
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
};
const mockUser = { id: userId };
@@ -177,7 +178,7 @@ describe('WorkspaceAgnosticToken', () => {
const mockPayload = {
sub: userId,
userId: userId,
type: 'WORKSPACE_AGNOSTIC',
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
};
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
@@ -16,6 +16,8 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Module({
imports: [
@@ -26,6 +28,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
WorkspaceEntity,
UserWorkspaceEntity,
ApiKeyEntity,
ApplicationEntity,
]),
TypeORMModule,
DataSourceModule,
@@ -38,6 +41,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
LoginTokenService,
RefreshTokenService,
WorkspaceAgnosticTokenService,
ApplicationTokenService,
],
exports: [
RenewTokenService,
@@ -45,6 +49,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
LoginTokenService,
RefreshTokenService,
WorkspaceAgnosticTokenService,
ApplicationTokenService,
],
})
export class TokenModule {}
@@ -3,12 +3,14 @@ import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
export type AuthContext = {
user?: UserEntity | null | undefined;
apiKey?: ApiKeyEntity | null | undefined;
workspaceMemberId?: string;
workspace?: WorkspaceEntity;
application?: ApplicationEntity | null | undefined;
userWorkspaceId?: string;
userWorkspace?: UserWorkspaceEntity;
authProvider?: AuthProviderEnum;
@@ -28,6 +30,7 @@ export enum JwtTokenTypeEnum {
POSTGRES_PROXY = 'POSTGRES_PROXY',
REMOTE_SERVER = 'REMOTE_SERVER',
KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY',
APPLICATION = 'APPLICATION',
}
type CommonPropertiesJwtPayload = {
@@ -83,6 +86,12 @@ export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & {
jti?: string;
};
export type ApplicationTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION;
workspaceId: string;
applicationId: string;
};
export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.ACCESS;
workspaceId: string;
@@ -106,6 +115,7 @@ export type RemoteServerTokenJwtPayload = CommonPropertiesJwtPayload & {
export type JwtPayload =
| AccessTokenJwtPayload
| ApiKeyTokenJwtPayload
| ApplicationTokenJwtPayload
| WorkspaceAgnosticTokenJwtPayload
| LoginTokenJwtPayload
| TransientTokenJwtPayload
@@ -52,6 +52,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => {
});
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
case AuthExceptionCode.APPLICATION_NOT_FOUND:
case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND:
throw new AuthenticationError(exception);
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
@@ -30,6 +30,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => {
case AuthExceptionCode.UNAUTHENTICATED:
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
case AuthExceptionCode.APPLICATION_NOT_FOUND:
return 401;
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND:
@@ -4,10 +4,7 @@ import {
Injectable,
} from '@nestjs/common';
import {
type FileTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@@ -28,7 +25,6 @@ export class FilePathGuard implements CanActivate {
try {
const payload = await this.jwtWrapperService.verifyJwtToken(
fileSignature,
JwtTokenTypeEnum.FILE,
ignoreExpirationToken ? { ignoreExpiration: true } : {},
);
@@ -17,13 +17,8 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
type AccessTokenJwtPayload,
type FileTokenJwtPayload,
type JwtPayload,
JwtTokenTypeEnum,
type RefreshTokenJwtPayload,
type TransientTokenJwtPayload,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -54,16 +49,10 @@ export class JwtWrapperService {
verifyJwtToken(
token: string,
type: JwtTokenTypeEnum,
options?: JwtVerifyOptions,
isLegacyApiKey = false,
) {
const payload = this.decode<
| TransientTokenJwtPayload
| RefreshTokenJwtPayload
| WorkspaceAgnosticTokenJwtPayload
| AccessTokenJwtPayload
| FileTokenJwtPayload
>(token, {
const payload = this.decode<JwtPayload>(token, {
json: true,
});
@@ -71,37 +60,56 @@ export class JwtWrapperService {
throw new AuthException('No payload', AuthExceptionCode.UNAUTHENTICATED);
}
// @TODO: Migrate to use type from payload instead of parameter
type =
payload.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
? JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
: type;
const type = payload.type;
// TODO: check if this is really needed
if (type !== 'FILE' && !payload.sub) {
const appSecretBody =
'workspaceId' in payload
? payload.workspaceId
: 'userId' in payload
? payload.userId
: undefined;
if (!isDefined(appSecretBody)) {
throw new AuthException(
'No payload sub',
AuthExceptionCode.UNAUTHENTICATED,
'Invalid token type',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
}
try {
// TODO: Deprecate this once old API KEY tokens are no longer in use
if (!payload.type && !('workspaceId' in payload) && type === 'ACCESS') {
// Supporting old API KEY tokens
if (
!payload.type &&
!('workspaceId' in payload) &&
type === JwtTokenTypeEnum.API_KEY
) {
return this.jwtService.verify(token, {
...options,
secret: this.generateAppSecretLegacy(),
});
}
const appSecretBody =
'workspaceId' in payload ? payload.workspaceId : payload.userId;
if (!isDefined(appSecretBody)) {
throw new AuthException(
'Invalid token type',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
// This is due to an unfortunate mistake in the secret generation of API_KEY
// tokens. We used to sign with ACCESS Jwt Token Type instead of API_KEY.
// Now we need to check both cases not to break the existing api keys
// See this PR for context -> https://github.com/twentyhq/twenty/pull/16504
// This code block can be deleted, but all api keys created before
// 12/12/2025 will be broken
if (type === JwtTokenTypeEnum.API_KEY && !isLegacyApiKey) {
try {
return this.jwtService.verify(token, {
...options,
secret: this.generateAppSecret(type, appSecretBody),
});
} catch {
return this.jwtService.verify(token, {
...options,
secret: this.generateAppSecret(
JwtTokenTypeEnum.ACCESS,
appSecretBody,
),
});
}
}
return this.jwtService.verify(token, {
@@ -114,17 +122,17 @@ export class JwtWrapperService {
'Token has expired.',
AuthExceptionCode.UNAUTHENTICATED,
);
} else if (error instanceof jwt.JsonWebTokenError) {
}
if (error instanceof jwt.JsonWebTokenError) {
throw new AuthException(
'Token invalid.',
AuthExceptionCode.UNAUTHENTICATED,
);
} else {
throw new AuthException(
'Unknown token error.',
AuthExceptionCode.INVALID_INPUT,
);
}
throw new AuthException(
'Unknown token error.',
AuthExceptionCode.INVALID_INPUT,
);
}
}
@@ -99,10 +99,10 @@ export class OpenApiService {
}
async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> {
const baseUrl = getServerUrl(
this.twentyConfigService.get('SERVER_URL'),
`${request.protocol}://${request.get('host')}`,
);
const baseUrl = getServerUrl({
serverUrlEnv: this.twentyConfigService.get('SERVER_URL'),
serverUrlFallback: `${request.protocol}://${request.get('host')}`,
});
const tokenFromQuery = request.query.token;
const schema = baseSchema(
@@ -270,10 +270,10 @@ export class OpenApiService {
async generateMetaDataSchema(
request: Request,
): Promise<OpenAPIV3_1.Document> {
const baseUrl = getServerUrl(
this.twentyConfigService.get('SERVER_URL'),
`${request.protocol}://${request.get('host')}`,
);
const baseUrl = getServerUrl({
serverUrlEnv: this.twentyConfigService.get('SERVER_URL'),
serverUrlFallback: `${request.protocol}://${request.get('host')}`,
});
const tokenFromQuery = request.query.token;
const schema = baseSchema(
@@ -18,9 +18,15 @@ export type ServerlessExecuteResult = {
// TODO refactor to be using FlatServerlessFunction
export interface ServerlessDriver {
delete(serverlessFunction: ServerlessFunctionEntity): Promise<void>;
execute(
serverlessFunction: ServerlessFunctionEntity,
payload: object,
version: string,
): Promise<ServerlessExecuteResult>;
execute({
serverlessFunction,
payload,
version,
env,
}: {
serverlessFunction: ServerlessFunctionEntity;
payload: object;
version: string;
env?: Record<string, string>;
}): Promise<ServerlessExecuteResult>;
}
@@ -27,7 +27,6 @@ import {
} from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface';
import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory';
import { copyAndBuildDependencies } from 'src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies';
import { copyExecutor } from 'src/engine/core-modules/serverless/drivers/utils/copy-executor';
@@ -305,11 +304,17 @@ export class LambdaDriver implements ServerlessDriver {
.trim();
}
async execute(
serverlessFunction: ServerlessFunctionEntity,
payload: object,
version: string,
): Promise<ServerlessExecuteResult> {
async execute({
serverlessFunction,
payload,
version,
env,
}: {
serverlessFunction: ServerlessFunctionEntity;
payload: object;
version: string;
env?: Record<string, string>;
}): Promise<ServerlessExecuteResult> {
await this.build(serverlessFunction);
await this.waitFunctionUpdates(serverlessFunction);
@@ -348,7 +353,7 @@ export class LambdaDriver implements ServerlessDriver {
const executorPayload: LambdaDriverExecutorPayload = {
params: payload,
code: compiledCode,
env: buildEnvVar(serverlessFunction),
env: env ?? {},
handlerName: serverlessFunction.handlerName,
};
@@ -9,7 +9,6 @@ import {
import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { SERVERLESS_TMPDIR_FOLDER } from 'src/engine/core-modules/serverless/drivers/constants/serverless-tmpdir-folder';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory';
import { copyAndBuildDependencies } from 'src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies';
import { formatBuildError } from 'src/engine/core-modules/serverless/drivers/utils/format-build-error';
@@ -61,11 +60,17 @@ export class LocalDriver implements ServerlessDriver {
await this.createLayerIfNotExists(serverlessFunction);
}
async execute(
serverlessFunction: ServerlessFunctionEntity,
payload: object,
version: string,
): Promise<ServerlessExecuteResult> {
async execute({
serverlessFunction,
payload,
version,
env,
}: {
serverlessFunction: ServerlessFunctionEntity;
payload: object;
version: string;
env?: Record<string, string>;
}): Promise<ServerlessExecuteResult> {
await this.build(serverlessFunction);
const startTime = Date.now();
@@ -154,7 +159,7 @@ export class LocalDriver implements ServerlessDriver {
const { ok, result, error, stack, stdout, stderr } =
await this.runChildWithEnv({
runnerPath,
env: buildEnvVar(serverlessFunction),
env: env ?? {},
payload,
timeoutMs: 900_000, // timeout is handled by the serverless function service
});
@@ -16,11 +16,17 @@ export class ServerlessService implements ServerlessDriver {
return this.driver.delete(serverlessFunction);
}
async execute(
serverlessFunction: ServerlessFunctionEntity,
payload: object,
version: string,
): Promise<ServerlessExecuteResult> {
return this.driver.execute(serverlessFunction, payload, version);
async execute({
serverlessFunction,
payload,
version,
env,
}: {
serverlessFunction: ServerlessFunctionEntity;
payload: object;
version: string;
env?: Record<string, string>;
}): Promise<ServerlessExecuteResult> {
return this.driver.execute({ serverlessFunction, payload, version, env });
}
}
@@ -9,6 +9,7 @@ import { isDefined } from 'twenty-shared/utils';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { bindDataToRequestObject } from 'src/engine/utils/bind-data-to-request-object.util';
@Injectable()
export class JwtAuthGuard implements CanActivate {
@@ -39,13 +40,7 @@ export class JwtAuthGuard implements CanActivate {
return false;
}
request.user = data.user;
request.apiKey = data.apiKey;
request.workspace = data.workspace;
request.workspaceId = data.workspace?.id;
request.workspaceMetadataVersion = metadataVersion;
request.workspaceMemberId = data.workspaceMemberId;
request.userWorkspaceId = data.userWorkspaceId;
bindDataToRequestObject(data, request, metadataVersion);
return true;
} catch (error) {
@@ -73,7 +73,6 @@ export const fromCreateAgentInputToFlatAgent = ({
userWorkspaceId: null,
agentId,
apiKeyId: null,
targetApplicationId: null,
createdAt,
updatedAt: createdAt,
universalIdentifier: v4(),
@@ -67,7 +67,6 @@ const computeAgentFlatRoleTargetToUpdate = ({
userWorkspaceId: null,
agentId: flatAgent.id,
apiKeyId: null,
targetApplicationId: null,
createdAt: updatedAt,
updatedAt,
universalIdentifier: v4(),
@@ -4,5 +4,4 @@ export const ROLE_TARGET_FOREIGN_KEY_PROPERTIES = [
'userWorkspaceId',
'apiKeyId',
'agentId',
'targetApplicationId',
] as const satisfies (keyof FlatRoleTarget)[];
@@ -11,7 +11,6 @@ export const fromRoleTargetsEntityToFlatRoleTarget = (
userWorkspaceId: roleTarget.userWorkspaceId,
agentId: roleTarget.agentId,
apiKeyId: roleTarget.apiKeyId,
targetApplicationId: roleTarget.targetApplicationId,
applicationId: roleTarget.applicationId,
universalIdentifier: roleTarget.universalIdentifier ?? roleTarget.id,
createdAt: roleTarget.createdAt.toISOString(),
@@ -50,7 +50,7 @@ export const fromCreateRoleInputToFlatRoleToCreate = ({
workspaceId,
createdAt: now,
updatedAt: now,
universalIdentifier: id,
universalIdentifier: createRoleInput.universalIdentifier ?? id,
applicationId,
roleTargetIds: [],
objectPermissionIds: [],
@@ -56,9 +56,6 @@ export class RoleTargetEntity extends SyncableEntity {
@Column({ nullable: true, type: 'uuid' })
apiKeyId: string | null;
@Column({ nullable: true, type: 'uuid' })
targetApplicationId: string | null;
@ManyToOne(() => ApiKeyEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'apiKeyId' })
apiKey: Relation<ApiKeyEntity>;
@@ -27,7 +27,6 @@ export const fromCreateRoleTargetInputToFlatRoleTargetToCreate = ({
userWorkspaceId: null,
agentId: null,
apiKeyId: null,
targetApplicationId: null,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
universalIdentifier: universalIdentifier ?? v4(),
@@ -1,4 +1,4 @@
import { Field, InputType } from '@nestjs/graphql';
import { Field, HideField, InputType } from '@nestjs/graphql';
import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator';
@@ -9,6 +9,9 @@ export class CreateRoleInput {
@Field({ nullable: true })
id?: string;
@HideField()
universalIdentifier?: string;
@IsString()
@Field({ nullable: false })
label: string;
@@ -88,22 +88,24 @@ export class RouteTriggerService {
request: Request;
workspaceId: string;
}) {
const { workspace } =
const authContext =
await this.accessTokenService.validateTokenByRequest(request);
if (!isDefined(workspace)) {
if (!isDefined(authContext.workspace)) {
throw new RouteTriggerException(
'Workspace not found',
RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND,
);
}
if (workspace.id !== workspaceId) {
if (authContext.workspace.id !== workspaceId) {
throw new RouteTriggerException(
'You are not authorized',
RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return authContext;
}
async handle({
@@ -137,12 +139,12 @@ export class RouteTriggerService {
};
const result =
await this.serverlessFunctionService.executeOneServerlessFunction(
routeTriggerWithPathParams.routeTrigger.serverlessFunction.id,
routeTriggerWithPathParams.routeTrigger.workspaceId,
executionParams,
'draft',
);
await this.serverlessFunctionService.executeOneServerlessFunction({
id: routeTriggerWithPathParams.routeTrigger.serverlessFunction.id,
workspaceId: routeTriggerWithPathParams.routeTrigger.workspaceId,
payload: executionParams,
version: 'draft',
});
if (!isDefined(result)) {
return result;
@@ -22,11 +22,11 @@ export class ServerlessFunctionTriggerJob {
@Process(ServerlessFunctionTriggerJob.name)
async handle(data: ServerlessFunctionTriggerJobData) {
await this.serverlessFunctionService.executeOneServerlessFunction(
data.serverlessFunctionId,
data.workspaceId,
data.payload || {},
'draft',
);
await this.serverlessFunctionService.executeOneServerlessFunction({
id: data.serverlessFunctionId,
workspaceId: data.workspaceId,
payload: data.payload || {},
version: 'draft',
});
}
}
@@ -24,6 +24,7 @@ import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverl
import { WorkspaceFlatServerlessFunctionMapCacheService } from 'src/engine/metadata-modules/serverless-function/services/workspace-flat-serverless-function-map-cache.service';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
@Module({
imports: [
@@ -45,6 +46,7 @@ import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.modu
WorkspaceMigrationV2Module,
ServerlessFunctionLayerModule,
SubscriptionsModule,
TokenModule,
],
providers: [
ServerlessFunctionService,
@@ -163,12 +163,12 @@ export class ServerlessFunctionResolver {
try {
const { id, payload, version } = input;
return await this.serverlessFunctionService.executeOneServerlessFunction(
return await this.serverlessFunctionService.executeOneServerlessFunction({
id,
workspaceId,
payload,
version,
);
});
} catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error);
}
@@ -8,6 +8,10 @@ import { isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
import { RedisPubSub } from 'graphql-redis-subscriptions';
import { Sources } from 'twenty-shared/types';
import {
DEFAULT_API_URL_NAME,
DEFAULT_API_KEY_NAME,
} from 'twenty-shared/application';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { type ServerlessExecuteResult } from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface';
@@ -33,6 +37,12 @@ import {
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { SERVERLESS_FUNCTION_LOGS_TRIGGER } from 'src/engine/metadata-modules/serverless-function/constants/serverless-function-logs-trigger';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { cleanServerUrl } from 'src/utils/clean-server-url';
const MIN_TOKEN_EXPIRATION_IN_SECONDS = 5;
@Injectable()
export class ServerlessFunctionService {
@@ -45,6 +55,8 @@ export class ServerlessFunctionService {
private readonly throttlerService: ThrottlerService,
private readonly twentyConfigService: TwentyConfigService,
private readonly auditService: AuditService,
private readonly accessTokenService: AccessTokenService,
private readonly applicationTokenService: ApplicationTokenService,
@Inject('PUB_SUB')
private readonly pubSub: RedisPubSub,
) {}
@@ -86,12 +98,17 @@ export class ServerlessFunctionService {
}
}
async executeOneServerlessFunction(
id: string,
workspaceId: string,
payload: object,
async executeOneServerlessFunction({
id,
workspaceId,
payload,
version = 'latest',
): Promise<ServerlessExecuteResult> {
}: {
id: string;
workspaceId: string;
payload: object;
version?: string;
}): Promise<ServerlessExecuteResult> {
await this.throttleExecution(workspaceId);
const functionToExecute =
@@ -102,14 +119,45 @@ export class ServerlessFunctionService {
},
relations: [
'serverlessFunctionLayer',
'application',
'application.applicationVariables',
],
});
const applicationAccessToken = isDefined(functionToExecute.applicationId)
? await this.applicationTokenService.generateApplicationToken({
workspaceId,
applicationId: functionToExecute.applicationId,
expiresInSeconds: Math.max(
functionToExecute.timeoutSeconds,
MIN_TOKEN_EXPIRATION_IN_SECONDS,
),
})
: undefined;
const baseUrl = cleanServerUrl(this.twentyConfigService.get('SERVER_URL'));
const envVariables = {
...(isDefined(baseUrl)
? {
[DEFAULT_API_URL_NAME]: baseUrl,
}
: {}),
...(isDefined(applicationAccessToken)
? {
[DEFAULT_API_KEY_NAME]: applicationAccessToken.token,
}
: {}),
...buildEnvVar(functionToExecute),
};
const resultServerlessFunction = await this.callWithTimeout({
callback: () =>
this.serverlessService.execute(functionToExecute, payload, version),
this.serverlessService.execute({
serverlessFunction: functionToExecute,
payload,
version,
env: envVariables,
}),
timeoutMs: functionToExecute.timeoutSeconds * 1000,
});
@@ -7,7 +7,6 @@ import { isDefined } from 'twenty-shared/utils';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { getAuthExceptionRestStatus } from 'src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@@ -21,6 +20,7 @@ import {
} from 'src/engine/utils/global-exception-handler.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { type CustomException } from 'src/utils/custom-exception';
import { bindDataToRequestObject } from 'src/engine/utils/bind-data-to-request-object.util';
@Injectable()
export class MiddlewareService {
@@ -116,7 +116,7 @@ export class MiddlewareService {
throw new Error('No data sources found');
}
this.bindDataToRequestObject(data, request, metadataVersion);
bindDataToRequestObject(data, request, metadataVersion);
}
public async hydrateGraphqlRequest(request: Request) {
@@ -135,35 +135,13 @@ export class MiddlewareService {
)
: undefined;
this.bindDataToRequestObject(data, request, metadataVersion);
bindDataToRequestObject(data, request, metadataVersion);
}
private hasErrorStatus(error: unknown): error is { status: number } {
return isDefined((error as { status: number })?.status);
}
private bindDataToRequestObject(
data: AuthContext,
request: Request,
metadataVersion: number | undefined,
) {
request.user = data.user;
request.apiKey = data.apiKey;
request.userWorkspace = data.userWorkspace;
request.workspace = data.workspace;
request.workspaceId = data.workspace?.id;
request.workspaceMetadataVersion = metadataVersion;
request.workspaceMemberId = data.workspaceMemberId;
request.userWorkspaceId = data.userWorkspaceId;
request.authProvider = data.authProvider;
request.impersonationContext = data.impersonationContext;
request.locale =
data.userWorkspace?.locale ??
(request.headers['x-locale'] as keyof typeof APP_LOCALES) ??
SOURCE_LOCALE;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private getStatus(error: any): number {
if (this.hasErrorStatus(error)) {
@@ -0,0 +1,27 @@
import { type Request } from 'express';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
export const bindDataToRequestObject = (
data: AuthContext,
request: Request,
metadataVersion: number | undefined,
) => {
request.user = data.user;
request.apiKey = data.apiKey;
request.application = data.application;
request.userWorkspace = data.userWorkspace;
request.workspace = data.workspace;
request.workspaceId = data.workspace?.id;
request.workspaceMetadataVersion = metadataVersion;
request.workspaceMemberId = data.workspaceMemberId;
request.userWorkspaceId = data.userWorkspaceId;
request.authProvider = data.authProvider;
request.impersonationContext = data.impersonationContext;
request.locale =
data.userWorkspace?.locale ??
(request.headers['x-locale'] as keyof typeof APP_LOCALES) ??
SOURCE_LOCALE;
};
@@ -48,12 +48,12 @@ export class CodeWorkflowAction implements WorkflowAction {
const { workspaceId } = runInfo;
const result =
await this.serverlessFunctionService.executeOneServerlessFunction(
workflowActionInput.serverlessFunctionId,
await this.serverlessFunctionService.executeOneServerlessFunction({
id: workflowActionInput.serverlessFunctionId,
workspaceId,
workflowActionInput.serverlessFunctionInput,
workflowActionInput.serverlessFunctionVersion,
);
payload: workflowActionInput.serverlessFunctionInput,
version: workflowActionInput.serverlessFunctionVersion,
});
if (result.error) {
return { error: result.error.errorMessage };
@@ -0,0 +1,6 @@
export const cleanServerUrl = (serverUrlEnv?: string) => {
if (serverUrlEnv?.endsWith('/'))
return serverUrlEnv.substring(0, serverUrlEnv.length - 1);
return serverUrlEnv;
};
@@ -1,9 +1,11 @@
export const getServerUrl = (
serverUrlEnv: string,
serverUrlFallback: string,
): string => {
if (serverUrlEnv?.endsWith('/'))
return serverUrlEnv.substring(0, serverUrlEnv.length - 1);
import { cleanServerUrl } from 'src/utils/clean-server-url';
return serverUrlEnv || serverUrlFallback;
export const getServerUrl = ({
serverUrlEnv,
serverUrlFallback,
}: {
serverUrlEnv?: string;
serverUrlFallback: string;
}): string => {
return cleanServerUrl(serverUrlEnv) || serverUrlFallback;
};
@@ -1,21 +0,0 @@
import { type ASTNode, print } from 'graphql';
import request from 'supertest';
type GraphqlOperation = {
query: ASTNode;
variables?: Record<string, unknown>;
};
export const makeGraphqlAPIRequestWithAcmeMemberRole = (
graphqlOperation: GraphqlOperation,
) => {
const client = request(`http://localhost:${APP_PORT}`);
return client
.post('/graphql')
.set('Authorization', `Bearer ${ACME_JONY_MEMBER_ACCESS_TOKEN}`)
.send({
query: print(graphqlOperation.query),
variables: graphqlOperation.variables || {},
});
};
@@ -4,10 +4,12 @@ import {
type Application,
} from '@/application';
import { type Sources } from '@/types';
import { type RoleManifest } from '@/application/roleManifestType';
export type ApplicationManifest = {
application: Application;
objects: ObjectManifest[];
serverlessFunctions: ServerlessFunctionManifest[];
roles?: RoleManifest[];
sources: Sources;
};
@@ -1,46 +1,10 @@
import { type ApplicationVariables } from '@/application';
import { type PermissionFlagType } from '@/constants';
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
type ObjectPermission = {
objectMetadataId: string;
canReadObjectRecords?: boolean;
canUpdateObjectRecords?: boolean;
canSoftDeleteObjectRecords?: boolean;
canDestroyObjectRecords?: boolean;
};
type FieldPermission = {
objectMetadataId: string;
fieldMetadataId: string;
canReadFieldValue?: boolean;
canUpdateFieldValue?: boolean;
};
type Role = {
label: string;
description?: string;
icon?: string;
canUpdateAllSettings?: boolean;
canAccessAllTools?: boolean;
canReadAllObjectRecords?: boolean;
canUpdateAllObjectRecords?: boolean;
canSoftDeleteAllObjectRecords?: boolean;
canDestroyAllObjectRecords?: boolean;
canBeAssignedToUsers?: boolean;
canBeAssignedToAgents?: boolean;
canBeAssignedToApiKeys?: boolean;
canBeAssignedToApplications?: boolean;
universalIdentifier: string;
objectPermissions?: ObjectPermission[];
fieldPermissions?: FieldPermission[];
permissionFlags?: PermissionFlagType[];
};
export type Application = {
universalIdentifier: string;
export type Application = SyncableEntityOptions & {
displayName?: string;
description?: string;
icon?: string;
applicationVariables?: ApplicationVariables;
applicationRole?: Role;
functionRoleUniversalIdentifier?: string;
};
@@ -1,5 +1,6 @@
type ApplicationVariable = {
universalIdentifier: string;
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
type ApplicationVariable = SyncableEntityOptions & {
value?: string;
description?: string;
isSecret?: boolean;
@@ -0,0 +1 @@
export const DEFAULT_API_KEY_NAME = 'TWENTY_API_KEY';
@@ -0,0 +1 @@
export const DEFAULT_API_URL_NAME = 'TWENTY_API_URL';
@@ -1,14 +1,23 @@
import { type FieldMetadataType } from '@/types';
import {
type FieldMetadataType,
type FieldMetadataSettings,
type FieldMetadataOptions,
type FieldMetadataDefaultValue,
} from '@/types';
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
export type FieldManifest = {
universalIdentifier: string;
type: FieldMetadataType;
export type FieldManifest<
T extends FieldMetadataType = Exclude<
FieldMetadataType,
FieldMetadataType.RELATION
>,
> = SyncableEntityOptions & {
type: T;
label: string;
description?: string;
icon?: string;
defaultValue?: any;
options?: any;
settings?: any;
defaultValue?: FieldMetadataDefaultValue<T>;
options?: FieldMetadataOptions<T>;
settings?: FieldMetadataSettings<T>;
isNullable?: boolean;
isFieldUiReadOnly?: boolean;
};
@@ -10,9 +10,12 @@
export type { ApplicationManifest } from './applicationManifestType';
export type { Application } from './applicationType';
export type { ApplicationVariables } from './applicationVariablesType';
export { DEFAULT_API_KEY_NAME } from './constants/DefaultApiKeyName';
export { DEFAULT_API_URL_NAME } from './constants/DefaultApiUrlName';
export type { FieldManifest } from './fieldManifestType';
export type { ObjectManifest } from './objectManifestType';
export type { PackageJson } from './packageJsonType';
export type { RoleManifest } from './roleManifestType';
export type {
ServerlessFunctionManifest,
DatabaseEventTrigger,
@@ -20,3 +23,4 @@ export type {
RouteTrigger,
ServerlessFunctionTriggerManifest,
} from './serverlessFunctionManifestType';
export type { SyncableEntityOptions } from './syncableEntityOptionsType';
@@ -1,7 +1,7 @@
import { type FieldManifest } from '@/application';
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
export type ObjectManifest = {
universalIdentifier: string;
export type ObjectManifest = SyncableEntityOptions & {
nameSingular: string;
namePlural: string;
labelSingular: string;
@@ -0,0 +1,63 @@
import { type PermissionFlagType } from '@/constants';
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
type WithObjectIdentifier = {
objectUniversalIdentifier: string;
objectNameSingular?: never;
};
type WithObjectName = {
objectNameSingular: string;
objectUniversalIdentifier?: never;
};
type BaseObjectPermission = {
canReadObjectRecords?: boolean;
canUpdateObjectRecords?: boolean;
canSoftDeleteObjectRecords?: boolean;
canDestroyObjectRecords?: boolean;
};
type ObjectPermission =
| (BaseObjectPermission & WithObjectIdentifier)
| (BaseObjectPermission & WithObjectName);
type WithFieldIdentifier = {
fieldUniversalIdentifier: string;
fieldName?: never;
};
type WithFieldName = {
fieldName: string;
fieldUniversalIdentifier?: never;
};
type BaseFieldPermission = {
canReadFieldValue?: boolean;
canUpdateFieldValue?: boolean;
};
type FieldPermission =
| (BaseFieldPermission & WithObjectIdentifier & WithFieldIdentifier)
| (BaseFieldPermission & WithObjectIdentifier & WithFieldName)
| (BaseFieldPermission & WithObjectName & WithFieldIdentifier)
| (BaseFieldPermission & WithObjectName & WithFieldName);
export type RoleManifest = SyncableEntityOptions & {
label: string;
description?: string;
icon?: string;
canUpdateAllSettings?: boolean;
canAccessAllTools?: boolean;
canReadAllObjectRecords?: boolean;
canUpdateAllObjectRecords?: boolean;
canSoftDeleteAllObjectRecords?: boolean;
canDestroyAllObjectRecords?: boolean;
canBeAssignedToUsers?: boolean;
canBeAssignedToAgents?: boolean;
canBeAssignedToApiKeys?: boolean;
canBeAssignedToApplications?: boolean;
objectPermissions?: ObjectPermission[];
fieldPermissions?: FieldPermission[];
permissionFlags?: PermissionFlagType[];
};
@@ -1,7 +1,7 @@
import { type HTTPMethod } from '@/types';
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
export type ServerlessFunctionManifest = {
universalIdentifier: string;
export type ServerlessFunctionManifest = SyncableEntityOptions & {
name?: string;
description?: string;
timeoutSeconds?: number;
@@ -23,10 +23,9 @@ export type CronTrigger = {
export type RouteTrigger = {
type: 'route';
path: string;
httpMethod: HTTPMethod;
httpMethod: `${HTTPMethod}`;
isAuthRequired: boolean;
};
export type ServerlessFunctionTriggerManifest = {
universalIdentifier: string;
} & (CronTrigger | DatabaseEventTrigger | RouteTrigger);
export type ServerlessFunctionTriggerManifest = SyncableEntityOptions &
(CronTrigger | DatabaseEventTrigger | RouteTrigger);

Some files were not shown because too many files have changed in this diff Show More