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
@@ -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,