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:
@@ -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',
|
||||
}
|
||||
|
||||
+4
@@ -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);
|
||||
}
|
||||
|
||||
+7
-1
@@ -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`,
|
||||
});
|
||||
});
|
||||
|
||||
+66
-2
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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 },
|
||||
|
||||
+3
@@ -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,
|
||||
};
|
||||
|
||||
+4
@@ -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',
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
});
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+17
-1
@@ -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,
|
||||
|
||||
+76
-63
@@ -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,
|
||||
|
||||
+57
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
};
|
||||
|
||||
+1
@@ -5,4 +5,5 @@ export type StandardAgentDefinition = Omit<
|
||||
'id' | 'workspaceId' | 'universalIdentifier' | 'standardId'
|
||||
> & {
|
||||
standardId: string;
|
||||
standardRoleId?: string;
|
||||
};
|
||||
|
||||
+2
@@ -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[];
|
||||
|
||||
+3
@@ -12,4 +12,7 @@ export const ADMIN_ROLE: StandardRoleDefinition = {
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: true,
|
||||
canBeAssignedToUsers: true,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToApiKeys: true,
|
||||
};
|
||||
|
||||
+20
@@ -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],
|
||||
};
|
||||
+2
@@ -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[];
|
||||
};
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user