1895 extensibility v1 application tokens 3 (#16504)

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

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

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

<img width="741" height="568" alt="image"
src="https://github.com/user-attachments/assets/7683944c-fd79-4417-8fb2-8e4815cc112f"
/>
This commit is contained in:
martmull
2025-12-15 17:44:23 +01:00
committed by GitHub
parent e33f18bfa8
commit e289f3056e
103 changed files with 1427 additions and 512 deletions
@@ -73,7 +73,6 @@ export const fromCreateAgentInputToFlatAgent = ({
userWorkspaceId: null,
agentId,
apiKeyId: null,
targetApplicationId: null,
createdAt,
updatedAt: createdAt,
universalIdentifier: v4(),
@@ -67,7 +67,6 @@ const computeAgentFlatRoleTargetToUpdate = ({
userWorkspaceId: null,
agentId: flatAgent.id,
apiKeyId: null,
targetApplicationId: null,
createdAt: updatedAt,
updatedAt,
universalIdentifier: v4(),
@@ -4,5 +4,4 @@ export const ROLE_TARGET_FOREIGN_KEY_PROPERTIES = [
'userWorkspaceId',
'apiKeyId',
'agentId',
'targetApplicationId',
] as const satisfies (keyof FlatRoleTarget)[];
@@ -11,7 +11,6 @@ export const fromRoleTargetsEntityToFlatRoleTarget = (
userWorkspaceId: roleTarget.userWorkspaceId,
agentId: roleTarget.agentId,
apiKeyId: roleTarget.apiKeyId,
targetApplicationId: roleTarget.targetApplicationId,
applicationId: roleTarget.applicationId,
universalIdentifier: roleTarget.universalIdentifier ?? roleTarget.id,
createdAt: roleTarget.createdAt.toISOString(),
@@ -50,7 +50,7 @@ export const fromCreateRoleInputToFlatRoleToCreate = ({
workspaceId,
createdAt: now,
updatedAt: now,
universalIdentifier: id,
universalIdentifier: createRoleInput.universalIdentifier ?? id,
applicationId,
roleTargetIds: [],
objectPermissionIds: [],
@@ -56,9 +56,6 @@ export class RoleTargetEntity extends SyncableEntity {
@Column({ nullable: true, type: 'uuid' })
apiKeyId: string | null;
@Column({ nullable: true, type: 'uuid' })
targetApplicationId: string | null;
@ManyToOne(() => ApiKeyEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'apiKeyId' })
apiKey: Relation<ApiKeyEntity>;
@@ -27,7 +27,6 @@ export const fromCreateRoleTargetInputToFlatRoleTargetToCreate = ({
userWorkspaceId: null,
agentId: null,
apiKeyId: null,
targetApplicationId: null,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
universalIdentifier: universalIdentifier ?? v4(),
@@ -1,4 +1,4 @@
import { Field, InputType } from '@nestjs/graphql';
import { Field, HideField, InputType } from '@nestjs/graphql';
import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator';
@@ -9,6 +9,9 @@ export class CreateRoleInput {
@Field({ nullable: true })
id?: string;
@HideField()
universalIdentifier?: string;
@IsString()
@Field({ nullable: false })
label: string;
@@ -88,22 +88,24 @@ export class RouteTriggerService {
request: Request;
workspaceId: string;
}) {
const { workspace } =
const authContext =
await this.accessTokenService.validateTokenByRequest(request);
if (!isDefined(workspace)) {
if (!isDefined(authContext.workspace)) {
throw new RouteTriggerException(
'Workspace not found',
RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND,
);
}
if (workspace.id !== workspaceId) {
if (authContext.workspace.id !== workspaceId) {
throw new RouteTriggerException(
'You are not authorized',
RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return authContext;
}
async handle({
@@ -137,12 +139,12 @@ export class RouteTriggerService {
};
const result =
await this.serverlessFunctionService.executeOneServerlessFunction(
routeTriggerWithPathParams.routeTrigger.serverlessFunction.id,
routeTriggerWithPathParams.routeTrigger.workspaceId,
executionParams,
'draft',
);
await this.serverlessFunctionService.executeOneServerlessFunction({
id: routeTriggerWithPathParams.routeTrigger.serverlessFunction.id,
workspaceId: routeTriggerWithPathParams.routeTrigger.workspaceId,
payload: executionParams,
version: 'draft',
});
if (!isDefined(result)) {
return result;
@@ -22,11 +22,11 @@ export class ServerlessFunctionTriggerJob {
@Process(ServerlessFunctionTriggerJob.name)
async handle(data: ServerlessFunctionTriggerJobData) {
await this.serverlessFunctionService.executeOneServerlessFunction(
data.serverlessFunctionId,
data.workspaceId,
data.payload || {},
'draft',
);
await this.serverlessFunctionService.executeOneServerlessFunction({
id: data.serverlessFunctionId,
workspaceId: data.workspaceId,
payload: data.payload || {},
version: 'draft',
});
}
}
@@ -24,6 +24,7 @@ import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverl
import { WorkspaceFlatServerlessFunctionMapCacheService } from 'src/engine/metadata-modules/serverless-function/services/workspace-flat-serverless-function-map-cache.service';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
@Module({
imports: [
@@ -45,6 +46,7 @@ import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.modu
WorkspaceMigrationV2Module,
ServerlessFunctionLayerModule,
SubscriptionsModule,
TokenModule,
],
providers: [
ServerlessFunctionService,
@@ -163,12 +163,12 @@ export class ServerlessFunctionResolver {
try {
const { id, payload, version } = input;
return await this.serverlessFunctionService.executeOneServerlessFunction(
return await this.serverlessFunctionService.executeOneServerlessFunction({
id,
workspaceId,
payload,
version,
);
});
} catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error);
}
@@ -8,6 +8,10 @@ import { isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
import { RedisPubSub } from 'graphql-redis-subscriptions';
import { Sources } from 'twenty-shared/types';
import {
DEFAULT_API_URL_NAME,
DEFAULT_API_KEY_NAME,
} from 'twenty-shared/application';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { type ServerlessExecuteResult } from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface';
@@ -33,6 +37,12 @@ import {
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { SERVERLESS_FUNCTION_LOGS_TRIGGER } from 'src/engine/metadata-modules/serverless-function/constants/serverless-function-logs-trigger';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { cleanServerUrl } from 'src/utils/clean-server-url';
const MIN_TOKEN_EXPIRATION_IN_SECONDS = 5;
@Injectable()
export class ServerlessFunctionService {
@@ -45,6 +55,8 @@ export class ServerlessFunctionService {
private readonly throttlerService: ThrottlerService,
private readonly twentyConfigService: TwentyConfigService,
private readonly auditService: AuditService,
private readonly accessTokenService: AccessTokenService,
private readonly applicationTokenService: ApplicationTokenService,
@Inject('PUB_SUB')
private readonly pubSub: RedisPubSub,
) {}
@@ -86,12 +98,17 @@ export class ServerlessFunctionService {
}
}
async executeOneServerlessFunction(
id: string,
workspaceId: string,
payload: object,
async executeOneServerlessFunction({
id,
workspaceId,
payload,
version = 'latest',
): Promise<ServerlessExecuteResult> {
}: {
id: string;
workspaceId: string;
payload: object;
version?: string;
}): Promise<ServerlessExecuteResult> {
await this.throttleExecution(workspaceId);
const functionToExecute =
@@ -102,14 +119,45 @@ export class ServerlessFunctionService {
},
relations: [
'serverlessFunctionLayer',
'application',
'application.applicationVariables',
],
});
const applicationAccessToken = isDefined(functionToExecute.applicationId)
? await this.applicationTokenService.generateApplicationToken({
workspaceId,
applicationId: functionToExecute.applicationId,
expiresInSeconds: Math.max(
functionToExecute.timeoutSeconds,
MIN_TOKEN_EXPIRATION_IN_SECONDS,
),
})
: undefined;
const baseUrl = cleanServerUrl(this.twentyConfigService.get('SERVER_URL'));
const envVariables = {
...(isDefined(baseUrl)
? {
[DEFAULT_API_URL_NAME]: baseUrl,
}
: {}),
...(isDefined(applicationAccessToken)
? {
[DEFAULT_API_KEY_NAME]: applicationAccessToken.token,
}
: {}),
...buildEnvVar(functionToExecute),
};
const resultServerlessFunction = await this.callWithTimeout({
callback: () =>
this.serverlessService.execute(functionToExecute, payload, version),
this.serverlessService.execute({
serverlessFunction: functionToExecute,
payload,
version,
env: envVariables,
}),
timeoutMs: functionToExecute.timeoutSeconds * 1000,
});