Feat: role applicability controls (#14239)

Closes [#1404](https://github.com/twentyhq/core-team-issues/issues/1404)

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-09-04 08:58:50 +05:30
committed by GitHub
parent 052f91cff1
commit 79bcd90d8d
72 changed files with 1889 additions and 344 deletions
@@ -55,6 +55,9 @@ describe('ApiKeyRoleService', () => {
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canBeAssignedToAgents: false,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: true,
};
const mockNewRole: Partial<RoleEntity> = {
@@ -427,6 +430,10 @@ describe('ApiKeyRoleService', () => {
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canBeAssignedToAgents: false,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: true,
standardId: undefined,
});
});
@@ -1,7 +1,14 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, type EntityManager, In, Repository } from 'typeorm';
import {
DataSource,
type EntityManager,
In,
IsNull,
Not,
Repository,
} from 'typeorm';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import {
@@ -145,6 +152,13 @@ export class ApiKeyRoleService {
);
}
if (!role.canBeAssignedToApiKeys) {
throw new ApiKeyException(
`Role "${role.label}" cannot be assigned to API keys`,
ApiKeyExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS,
);
}
const existingRoleTarget = await this.roleTargetsRepository.findOne({
where: {
apiKeyId,
@@ -190,4 +204,35 @@ export class ApiKeyRoleService {
return rolesMap;
}
public async getApiKeysAssignedToRole(
roleId: string,
workspaceId: string,
): Promise<ApiKey[]> {
const roleTargets = await this.roleTargetsRepository.find({
where: {
roleId,
workspaceId,
apiKeyId: Not(IsNull()),
},
});
const apiKeyIds = roleTargets
.map((roleTarget) => roleTarget.apiKeyId)
.filter((apiKeyId): apiKeyId is string => apiKeyId !== null);
if (!apiKeyIds.length) {
return [];
}
const apiKeys = await this.apiKeyRepository.find({
where: {
id: In(apiKeyIds),
workspaceId,
revokedAt: IsNull(),
},
});
return apiKeys;
}
}
@@ -7,4 +7,5 @@ export enum ApiKeyExceptionCode {
API_KEY_REVOKED = 'API_KEY_REVOKED',
API_KEY_EXPIRED = 'API_KEY_EXPIRED',
API_KEY_NO_ROLE_ASSIGNED = 'API_KEY_NO_ROLE_ASSIGNED',
ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS = 'ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS',
}
@@ -27,6 +27,10 @@ export const apiKeyGraphqlApiExceptionHandler = (error: Error) => {
throw new ForbiddenError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
case ApiKeyExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
return assertUnreachable(error.code);
}
@@ -87,6 +87,9 @@ describe('AgentRoleService', () => {
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToAgents: true,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: true,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -102,6 +105,9 @@ describe('AgentRoleService', () => {
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToAgents: true,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: true,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -288,7 +294,7 @@ describe('AgentRoleService', () => {
roleId: nonExistentRoleId,
}),
).rejects.toMatchObject({
code: AgentExceptionCode.AGENT_EXECUTION_FAILED,
code: AgentExceptionCode.ROLE_NOT_FOUND,
message: `Role with id ${nonExistentRoleId} not found in workspace`,
});
});
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import {
@@ -54,6 +54,33 @@ export class AgentRoleService {
});
}
public async assignStandardRoleToAgent({
workspaceId,
agentId,
standardRoleId,
}: {
workspaceId: string;
agentId: string;
standardRoleId: string;
}) {
const role = await this.roleRepository.findOne({
where: { standardId: standardRoleId, workspaceId },
});
if (!role) {
throw new AgentException(
`Standard role with standard ID ${standardRoleId} not found in workspace`,
AgentExceptionCode.ROLE_NOT_FOUND,
);
}
await this.assignRoleToAgent({
workspaceId,
agentId,
roleId: role.id,
});
}
public async removeRoleFromAgent({
workspaceId,
agentId,
@@ -67,6 +94,36 @@ export class AgentRoleService {
});
}
public async getAgentsAssignedToRole(
roleId: string,
workspaceId: string,
): Promise<AgentEntity[]> {
const roleTargets = await this.roleTargetsRepository.find({
where: {
roleId,
workspaceId,
agentId: Not(IsNull()),
},
});
const agentIds = roleTargets
.map((roleTarget) => roleTarget.agentId)
.filter((agentId): agentId is string => agentId !== null);
if (!agentIds.length) {
return [];
}
const agents = await this.agentRepository.find({
where: {
id: In(agentIds),
workspaceId,
},
});
return agents;
}
private async validateAssignRoleInput({
agentId,
workspaceId,
@@ -94,7 +151,14 @@ export class AgentRoleService {
if (!role) {
throw new AgentException(
`Role with id ${roleId} not found in workspace`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
AgentExceptionCode.ROLE_NOT_FOUND,
);
}
if (!role.canBeAssignedToAgents) {
throw new AgentException(
`Role "${role.label}" cannot be assigned to agents`,
AgentExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS,
);
}
@@ -49,6 +49,7 @@ export class AgentToolGeneratorService {
id: roleId,
workspaceId,
},
relations: ['permissionFlags'],
});
if (!role) {
@@ -9,4 +9,5 @@ export enum AgentExceptionCode {
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
HANDOFF_ALREADY_EXISTS = 'HANDOFF_ALREADY_EXISTS',
ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS',
}
@@ -38,10 +38,7 @@ export class AgentResolver {
@Query(() => [AgentDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async findManyAgents(@AuthWorkspace() { id: workspaceId }: Workspace) {
return this.agentRepository.find({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
return this.agentService.findManyAgents(workspaceId);
}
@Query(() => AgentDTO)
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
import { type CreateAgentInput } from 'src/engine/metadata-modules/agent/dtos/create-agent.input';
@@ -23,6 +23,37 @@ export class AgentService {
private readonly agentRoleService: AgentRoleService,
) {}
async findManyAgents(workspaceId: string) {
const agents = await this.agentRepository.find({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
if (agents.length === 0) {
return [];
}
const roleTargets = await this.roleTargetsRepository.find({
where: {
workspaceId,
agentId: In(agents.map((agent) => agent.id)),
},
});
const agentRoleMap = new Map<string, string>();
roleTargets.forEach((roleTarget) => {
if (roleTarget.agentId) {
agentRoleMap.set(roleTarget.agentId, roleTarget.roleId);
}
});
return agents.map((agent) => ({
...agent,
roleId: agentRoleMap.get(agent.id) || null,
}));
}
async findOneAgent(id: string, workspaceId: string) {
const agent = await this.agentRepository.findOne({
where: { id, workspaceId },
@@ -15,6 +15,9 @@ export const fromRoleEntityToFlatRole = (role: RoleEntity): FlatRole => {
canUpdateAllObjectRecords: role.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords: role.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords: role.canDestroyAllObjectRecords,
canBeAssignedToUsers: role.canBeAssignedToUsers,
canBeAssignedToAgents: role.canBeAssignedToAgents,
canBeAssignedToApiKeys: role.canBeAssignedToApiKeys,
workspaceId: role.workspaceId,
universalIdentifier: role.standardId || role.id,
};
@@ -49,6 +49,8 @@ export enum PermissionsExceptionCode {
EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'EMPTY_FIELD_PERMISSION_NOT_ALLOWED',
JOIN_COLUMN_NAME_REQUIRED = 'JOIN_COLUMN_NAME_REQUIRED',
COMPOSITE_TYPE_NOT_FOUND = 'COMPOSITE_TYPE_NOT_FOUND',
ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET = 'ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET',
ROLE_CANNOT_BE_ASSIGNED_TO_USERS = 'ROLE_CANNOT_BE_ASSIGNED_TO_USERS',
}
export enum PermissionsExceptionMessage {
@@ -78,4 +80,6 @@ export enum PermissionsExceptionMessage {
FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT = 'Field restriction on update only makes sense on updatable object',
OBJECT_PERMISSION_NOT_FOUND = 'Object permission not found',
EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'Empty field permission not allowed',
ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET = 'Role must be assignable to at least one target type',
ROLE_CANNOT_BE_ASSIGNED_TO_USERS = 'Role cannot be assigned to users',
}
@@ -41,6 +41,8 @@ export const permissionGraphqlApiExceptionHandler = (
case PermissionsExceptionCode.FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT:
case PermissionsExceptionCode.FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT:
case PermissionsExceptionCode.EMPTY_FIELD_PERMISSION_NOT_ALLOWED:
case PermissionsExceptionCode.ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET:
case PermissionsExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_USERS:
throw new UserInputError(error);
case PermissionsExceptionCode.ROLE_NOT_FOUND:
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
@@ -52,4 +52,19 @@ export class CreateRoleInput {
@IsOptional()
@Field({ nullable: true })
canDestroyAllObjectRecords?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToUsers?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToAgents?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToApiKeys?: boolean;
}
@@ -4,11 +4,27 @@ import { Relation } from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
import { FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto';
import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto';
import { PermissionFlagDTO } from 'src/engine/metadata-modules/permission-flag/dtos/permission-flag.dto';
import { type RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
@ObjectType('ApiKeyForRole')
export class ApiKeyForRoleDTO {
@Field(() => UUIDScalarType, { nullable: false })
id: string;
@Field({ nullable: false })
name: string;
@Field(() => Date, { nullable: false })
expiresAt: Date;
@Field(() => Date, { nullable: true })
revokedAt?: Date | null;
}
@ObjectType('Role')
export class RoleDTO {
@Field(() => UUIDScalarType, { nullable: false })
@@ -29,12 +45,27 @@ export class RoleDTO {
@Field({ nullable: false })
isEditable: boolean;
@Field({ nullable: false })
canBeAssignedToUsers: boolean;
@Field({ nullable: false })
canBeAssignedToAgents: boolean;
@Field({ nullable: false })
canBeAssignedToApiKeys: boolean;
@HideField()
roleTargets: Relation<RoleTargetsEntity[]>;
@Field(() => [WorkspaceMember], { nullable: true })
workspaceMembers?: WorkspaceMember[];
@Field(() => [AgentDTO], { nullable: true })
agents?: AgentDTO[];
@Field(() => [ApiKeyForRoleDTO], { nullable: true })
apiKeys?: ApiKeyForRoleDTO[];
@Field({ nullable: false })
canUpdateAllSettings: boolean;
@@ -56,6 +56,21 @@ export class UpdateRolePayload {
@IsOptional()
@Field({ nullable: true })
canDestroyAllObjectRecords?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToUsers?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToAgents?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToApiKeys?: boolean;
}
@InputType()
@@ -62,6 +62,15 @@ export class RoleEntity {
@Column({ nullable: false, default: true })
isEditable: boolean;
@Column({ nullable: false, default: true })
canBeAssignedToUsers: boolean;
@Column({ nullable: false, default: true })
canBeAssignedToAgents: boolean;
@Column({ nullable: false, default: true })
canBeAssignedToApiKeys: boolean;
@OneToMany(
() => RoleTargetsEntity,
(roleTargets: RoleTargetsEntity) => roleTargets.role,
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
@@ -22,6 +23,7 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
TypeOrmModule.forFeature([UserWorkspace, Workspace]),
UserRoleModule,
AgentRoleModule,
ApiKeyModule,
PermissionsModule,
UserWorkspaceModule,
ObjectPermissionModule,
@@ -9,6 +9,7 @@ import {
} from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -22,6 +23,7 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
import { FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto';
import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto';
import { UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input';
@@ -39,7 +41,10 @@ import {
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { CreateRoleInput } from 'src/engine/metadata-modules/role/dtos/create-role-input.dto';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import {
ApiKeyForRoleDTO,
RoleDTO,
} from 'src/engine/metadata-modules/role/dtos/role.dto';
import { UpdateRoleInput } from 'src/engine/metadata-modules/role/dtos/update-role-input.dto';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -63,6 +68,7 @@ export class RoleResolver {
private readonly objectPermissionService: ObjectPermissionService,
private readonly settingPermissionService: PermissionFlagService,
private readonly agentRoleService: AgentRoleService,
private readonly apiKeyRoleService: ApiKeyRoleService,
private readonly fieldPermissionService: FieldPermissionService,
) {}
@@ -243,4 +249,35 @@ export class RoleResolver {
return workspaceMembers;
}
@ResolveField('agents', () => [AgentDTO])
async getAgentsAssignedToRole(
@Parent() role: RoleDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<AgentDTO[]> {
const agents = await this.agentRoleService.getAgentsAssignedToRole(
role.id,
workspace.id,
);
return agents;
}
@ResolveField('apiKeys', () => [ApiKeyForRoleDTO])
async getApiKeysAssignedToRole(
@Parent() role: RoleDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<ApiKeyForRoleDTO[]> {
const apiKeys = await this.apiKeyRoleService.getApiKeysAssignedToRole(
role.id,
workspace.id,
);
return apiKeys.map((apiKey) => ({
id: apiKey.id,
name: apiKey.name,
expiresAt: apiKey.expiresAt,
revokedAt: apiKey.revokedAt,
}));
}
}
@@ -83,6 +83,9 @@ export class RoleService {
canUpdateAllObjectRecords: input.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords: input.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords: input.canDestroyAllObjectRecords,
canBeAssignedToUsers: input.canBeAssignedToUsers,
canBeAssignedToAgents: input.canBeAssignedToAgents,
canBeAssignedToApiKeys: input.canBeAssignedToApiKeys,
isEditable: true,
workspaceId,
});
@@ -231,6 +234,9 @@ export class RoleService {
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: true,
canBeAssignedToAgents: false,
canBeAssignedToApiKeys: false,
isEditable: false,
workspaceId,
});
@@ -15,6 +15,9 @@ export const fromRoleEntityToRoleDto = (role: RoleEntity): RoleDTO => {
canUpdateAllObjectRecords: role.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords: role.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords: role.canDestroyAllObjectRecords,
canBeAssignedToUsers: role.canBeAssignedToUsers,
canBeAssignedToAgents: role.canBeAssignedToAgents,
canBeAssignedToApiKeys: role.canBeAssignedToApiKeys,
roleTargets: role.roleTargets,
};
};
@@ -258,6 +258,17 @@ export class UserRoleService {
);
}
if (!role.canBeAssignedToUsers) {
throw new PermissionsException(
`Role "${role.label}" cannot be assigned to users`,
PermissionsExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_USERS,
{
userFriendlyMessage:
'This role cannot be assigned to users. Please select a different role.',
},
);
}
const roles = await this.getRolesByUserWorkspaces({
userWorkspaceIds: [userWorkspace.id],
workspaceId,
@@ -49,11 +49,27 @@ export class WorkspaceAgentComparator {
switch (difference.type) {
case 'CREATE': {
const fromAgent = fromFlatAgents.find(
(agent) => keyFactory(agent) === universalIdentifier,
);
const toAgent = toFlatAgents.find(
(agent) => keyFactory(agent) === universalIdentifier,
);
if (toAgent) {
if (!toAgent) {
break;
}
if (fromAgent) {
fromAgent &&
results.push({
action: ComparatorAction.UPDATE,
object: {
...toAgent,
id: fromAgent.id,
},
});
} else {
results.push({
action: ComparatorAction.CREATE,
object: toAgent,
@@ -1,11 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { removePropertiesFromRecord } from 'twenty-shared/utils';
import { IsNull, Not, type EntityManager, type Repository } from 'typeorm';
import { IsNull, Not, type EntityManager } from 'typeorm';
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { type WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { transformAgentEntityToFlatAgent } from 'src/engine/metadata-modules/flat-agent/utils/transform-agent-entity-to-flat-agent.util';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
@@ -19,7 +20,6 @@ import { WorkspaceAgentComparator } from 'src/engine/workspace-manager/workspace
import { StandardAgentFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-agent.factory';
import { standardAgentDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents';
import { WORKFLOW_CREATION_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/workflow-creation-agent';
import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role';
@Injectable()
export class WorkspaceSyncAgentService {
@@ -28,6 +28,7 @@ export class WorkspaceSyncAgentService {
constructor(
private readonly standardAgentFactory: StandardAgentFactory,
private readonly workspaceAgentComparator: WorkspaceAgentComparator,
private readonly agentRoleService: AgentRoleService,
) {}
async synchronize(
@@ -37,8 +38,6 @@ export class WorkspaceSyncAgentService {
this.logger.log('Syncing standard agent.');
const agentRepository = manager.getRepository(AgentEntity);
const roleRepository = manager.getRepository(RoleEntity);
const roleTargetsRepository = manager.getRepository(RoleTargetsEntity);
const existingStandardAgentEntities = await agentRepository.find({
where: {
@@ -75,13 +74,43 @@ export class WorkspaceSyncAgentService {
workspaceId: context.workspaceId,
});
await this.assignAdminRoleToAgent(
createdAgent.id,
context.workspaceId,
roleRepository,
roleTargetsRepository,
const agentDefinition = standardAgentDefinitions.find(
(def) => def.standardId === createdAgent.standardId,
);
if (agentDefinition?.standardRoleId) {
try {
const roleRepository = manager.getRepository(RoleEntity);
const role = await roleRepository.findOne({
where: {
standardId: agentDefinition.standardRoleId,
workspaceId: context.workspaceId,
},
});
if (!role) {
throw new Error(
`Standard role with standard ID ${agentDefinition.standardRoleId} not found in workspace`,
);
}
const roleTargetsRepository =
manager.getRepository(RoleTargetsEntity);
await roleTargetsRepository.save({
roleId: role.id,
agentId: createdAgent.id,
workspaceId: context.workspaceId,
});
} catch (error) {
this.logger.error(
`Failed to assign standard role ${agentDefinition.standardRoleId} to agent ${createdAgent.id}: ${error.message}`,
error.stack,
);
throw error;
}
}
if (createdAgent.standardId === WORKFLOW_CREATION_AGENT.standardId) {
await this.createAgentHandoffToWorkflowCreationAgent(
createdAgent.id,
@@ -99,9 +128,47 @@ export class WorkspaceSyncAgentService {
'id',
'universalIdentifier',
'workspaceId',
'standardRoleId' as keyof typeof agentToUpdate,
]);
await agentRepository.update({ id: agentToUpdate.id }, flatAgentData);
const agentDefinition = standardAgentDefinitions.find(
(def) => def.standardId === agentToUpdate.standardId,
);
if (agentDefinition?.standardRoleId) {
try {
const roleRepository = manager.getRepository(RoleEntity);
const role = await roleRepository.findOne({
where: {
standardId: agentDefinition.standardRoleId,
workspaceId: context.workspaceId,
},
});
if (!role) {
throw new Error(
`Standard role with standard ID ${agentDefinition.standardRoleId} not found in workspace`,
);
}
const roleTargetsRepository =
manager.getRepository(RoleTargetsEntity);
await roleTargetsRepository.save({
roleId: role.id,
agentId: agentToUpdate.id,
workspaceId: context.workspaceId,
});
} catch (error) {
this.logger.error(
`Failed to assign standard role ${agentDefinition.standardRoleId} to agent ${agentToUpdate.id}: ${error.message}`,
error.stack,
);
throw error;
}
}
break;
}
@@ -115,60 +182,6 @@ export class WorkspaceSyncAgentService {
}
}
private async assignAdminRoleToAgent(
agentId: string,
workspaceId: string,
roleRepository: Repository<RoleEntity>,
roleTargetsRepository: Repository<RoleTargetsEntity>,
): Promise<void> {
try {
const adminRole = await roleRepository.findOne({
where: {
workspaceId,
standardId: ADMIN_ROLE.standardId,
},
});
if (!adminRole) {
this.logger.warn(
`Admin role not found for workspace ${workspaceId}, cannot assign to agent ${agentId}.`,
);
return;
}
const existingRoleTarget = await roleTargetsRepository.findOne({
where: {
agentId,
roleId: adminRole.id,
workspaceId,
},
});
if (existingRoleTarget) {
this.logger.log(
`Workflow creation agent already has admin role assigned`,
);
return;
}
await roleTargetsRepository.save({
roleId: adminRole.id,
agentId,
workspaceId,
});
this.logger.log(
`Successfully assigned admin role to workflow creation agent`,
);
} catch (error) {
this.logger.error(
`Failed to assign admin role to workflow creation agent: ${error.message}`,
);
}
}
private async createAgentHandoffToWorkflowCreationAgent(
workflowCreationAgentId: string,
workspaceId: string,
@@ -1,12 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import { removePropertiesFromRecord } from 'twenty-shared/utils';
import { IsNull, Not, type EntityManager } from 'typeorm';
import { IsNull, Not, type EntityManager, type Repository } from 'typeorm';
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { type WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { fromRoleEntityToFlatRole } from 'src/engine/metadata-modules/flat-role/utils/from-role-entity-to-flat-role.util';
import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspaceRoleComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-role.comparator';
import { StandardRoleFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-role.factory';
@@ -28,12 +30,15 @@ export class WorkspaceSyncRoleService {
this.logger.log('Syncing standard role metadata');
const roleRepository = manager.getRepository(RoleEntity);
const permissionFlagRepository =
manager.getRepository(PermissionFlagEntity);
const existingStandardRoleEntities = await roleRepository.find({
where: {
workspaceId: context.workspaceId,
standardId: Not(IsNull()),
},
relations: ['permissionFlags'],
});
const targetStandardRoles = this.standardRoleFactory.create(
@@ -57,10 +62,23 @@ export class WorkspaceSyncRoleService {
'id',
]);
await roleRepository.save({
const createdRole = await roleRepository.save({
...flatRoleData,
workspaceId: context.workspaceId,
});
const roleDefinition = standardRoleDefinitions.find(
(def) => def.standardId === roleToCreate.standardId,
);
if (roleDefinition?.permissionFlags?.length) {
await this.syncPermissionFlags(
permissionFlagRepository,
createdRole.id,
context.workspaceId,
roleDefinition.permissionFlags,
);
}
break;
}
@@ -74,6 +92,19 @@ export class WorkspaceSyncRoleService {
]);
await roleRepository.update({ id: roleToUpdate.id }, flatRoleData);
const roleDefinition = standardRoleDefinitions.find(
(def) => def.standardId === roleToUpdate.standardId,
);
if (roleDefinition?.permissionFlags) {
await this.syncPermissionFlags(
permissionFlagRepository,
roleToUpdate.id,
context.workspaceId,
roleDefinition.permissionFlags,
);
}
break;
}
@@ -86,4 +117,28 @@ export class WorkspaceSyncRoleService {
}
}
}
private async syncPermissionFlags(
permissionFlagRepository: Repository<PermissionFlagEntity>,
roleId: string,
workspaceId: string,
permissionFlags: PermissionFlagType[],
): Promise<void> {
await permissionFlagRepository.delete({
roleId,
workspaceId,
});
if (permissionFlags.length > 0) {
const newPermissionFlags = permissionFlags.map((flag) =>
permissionFlagRepository.create({
roleId,
workspaceId,
flag,
}),
);
await permissionFlagRepository.save(newPermissionFlags);
}
}
}
@@ -1,4 +1,5 @@
import { type StandardAgentDefinition } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface';
import { WORKFLOW_MANAGER_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/workflow-manager-role';
export const WORKFLOW_CREATION_AGENT: StandardAgentDefinition = {
standardId: '20202020-0002-0001-0001-000000000001',
@@ -44,4 +45,5 @@ Be helpful, thorough, and always prioritize user understanding and workflow effe
modelId: 'auto',
responseFormat: {},
isCustom: false,
standardRoleId: WORKFLOW_MANAGER_ROLE.standardId,
};
@@ -5,4 +5,5 @@ export type StandardAgentDefinition = Omit<
'id' | 'workspaceId' | 'universalIdentifier' | 'standardId'
> & {
standardId: string;
standardRoleId?: string;
};
@@ -1,6 +1,8 @@
import { ADMIN_ROLE } from './roles/admin-role';
import { WORKFLOW_MANAGER_ROLE } from './roles/workflow-manager-role';
import { type StandardRoleDefinition } from './types/standard-role-definition.interface';
export const standardRoleDefinitions = [
ADMIN_ROLE,
WORKFLOW_MANAGER_ROLE,
] as const satisfies StandardRoleDefinition[];
@@ -12,4 +12,7 @@ export const ADMIN_ROLE: StandardRoleDefinition = {
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canBeAssignedToUsers: true,
canBeAssignedToAgents: false,
canBeAssignedToApiKeys: true,
};
@@ -0,0 +1,20 @@
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { type StandardRoleDefinition } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/types/standard-role-definition.interface';
export const WORKFLOW_MANAGER_ROLE: StandardRoleDefinition = {
standardId: '20202020-0001-0001-0001-000000000002',
label: 'Workflow Manager',
description: 'Role for managing workflows',
icon: 'IconSettingsAutomation',
isEditable: false,
canUpdateAllSettings: false,
canAccessAllTools: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: false,
permissionFlags: [PermissionFlagType.WORKFLOWS],
};
@@ -1,8 +1,10 @@
import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type';
import { type PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
export type StandardRoleDefinition = Omit<
FlatRole,
'id' | 'workspaceId' | 'universalIdentifier' | 'standardId'
> & {
standardId: string;
permissionFlags?: PermissionFlagType[];
};
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentRoleModule } from 'src/engine/metadata-modules/agent-role/agent-role.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -38,6 +39,7 @@ import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/works
DataSourceModule,
TypeOrmModule.forFeature([Workspace, FeatureFlag]),
WorkspaceMetadataVersionModule,
AgentRoleModule,
],
providers: [
...workspaceSyncMetadataFactories,