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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-8
@@ -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],
|
||||
};
|
||||
@@ -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'];
|
||||
|
||||
+1
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
+93
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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 {}
|
||||
+2
@@ -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],
|
||||
|
||||
+7
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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,
|
||||
|
||||
+6
-1
@@ -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);
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+8
-1
@@ -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',
|
||||
);
|
||||
|
||||
+15
@@ -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: {},
|
||||
});
|
||||
+1
-1
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+145
-81
@@ -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,
|
||||
|
||||
+81
-18
@@ -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,
|
||||
|
||||
+51
-6
@@ -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,
|
||||
|
||||
+1
-4
@@ -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,
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
+159
@@ -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);
|
||||
});
|
||||
});
|
||||
+80
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+6
-8
@@ -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,
|
||||
});
|
||||
|
||||
+1
-4
@@ -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,
|
||||
|
||||
+5
-8
@@ -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',
|
||||
|
||||
+1
-4
@@ -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);
|
||||
|
||||
|
||||
+3
-5
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+1
-4
@@ -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);
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+1
@@ -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:
|
||||
|
||||
+1
@@ -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 } : {},
|
||||
);
|
||||
|
||||
|
||||
+46
-38
@@ -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(
|
||||
|
||||
+11
-5
@@ -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) {
|
||||
|
||||
-1
@@ -73,7 +73,6 @@ export const fromCreateAgentInputToFlatAgent = ({
|
||||
userWorkspaceId: null,
|
||||
agentId,
|
||||
apiKeyId: null,
|
||||
targetApplicationId: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
universalIdentifier: v4(),
|
||||
|
||||
-1
@@ -67,7 +67,6 @@ const computeAgentFlatRoleTargetToUpdate = ({
|
||||
userWorkspaceId: null,
|
||||
agentId: flatAgent.id,
|
||||
apiKeyId: null,
|
||||
targetApplicationId: null,
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
universalIdentifier: v4(),
|
||||
|
||||
-1
@@ -4,5 +4,4 @@ export const ROLE_TARGET_FOREIGN_KEY_PROPERTIES = [
|
||||
'userWorkspaceId',
|
||||
'apiKeyId',
|
||||
'agentId',
|
||||
'targetApplicationId',
|
||||
] as const satisfies (keyof FlatRoleTarget)[];
|
||||
|
||||
-1
@@ -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(),
|
||||
|
||||
+1
-1
@@ -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>;
|
||||
|
||||
-1
@@ -27,7 +27,6 @@ export const fromCreateRoleTargetInputToFlatRoleTargetToCreate = ({
|
||||
userWorkspaceId: null,
|
||||
agentId: null,
|
||||
apiKeyId: null,
|
||||
targetApplicationId: null,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
universalIdentifier: universalIdentifier ?? v4(),
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
+11
-9
@@ -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;
|
||||
|
||||
+6
-6
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
+55
-7
@@ -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;
|
||||
};
|
||||
+5
-5
@@ -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;
|
||||
};
|
||||
|
||||
-21
@@ -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
Reference in New Issue
Block a user