feat: agent roleUniversalIdentifier for manifest-driven role assignment (#23206)

## Summary
- Adds optional `roleUniversalIdentifier` on `AgentManifest` /
`defineAgent` so apps can declaratively assign a role to an agent (same
config shape as `defaultRoleUniversalIdentifier`).
- Wires `agentUniversalIdentifier` as a sync many-to-one FK on
`roleTarget`, and emits a deterministic `roleTarget` from the agent
during app sync (create / update / delete).
- Enables app agents (e.g. Slack assistant) to get a role on install
without postInstall hooks or manual admin assignment.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23206?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Abdul Rahman
2026-07-24 05:57:40 +05:30
committed by GitHub
parent 8b7ac02464
commit 2c79093b74
30 changed files with 507 additions and 42 deletions
@@ -77,6 +77,7 @@ export const fromCreateAgentInputToFlatAgent = ({
roleUniversalIdentifier,
userWorkspaceId: null,
agentId,
agentUniversalIdentifier: flatAgentToCreate.universalIdentifier,
apiKeyId: null,
createdAt,
updatedAt: createdAt,
@@ -66,6 +66,7 @@ const computeAgentFlatRoleTargetToUpdate = ({
...existingRoleTarget,
roleId,
roleUniversalIdentifier: flatRole.universalIdentifier,
agentUniversalIdentifier: flatAgent.universalIdentifier,
updatedAt,
},
};
@@ -78,6 +79,7 @@ const computeAgentFlatRoleTargetToUpdate = ({
roleUniversalIdentifier: flatRole.universalIdentifier,
userWorkspaceId: null,
agentId: flatAgent.id,
agentUniversalIdentifier: flatAgent.universalIdentifier,
apiKeyId: null,
createdAt: updatedAt,
updatedAt,
@@ -7,6 +7,7 @@ import { IsNull, Not, Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { FlatRoleTargetByAgentIdMaps } from 'src/engine/metadata-modules/flat-agent/types/flat-role-target-by-agent-id-maps.type';
import { fromRoleTargetEntityToFlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/utils/from-role-target-entity-to-flat-role-target.util';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
@@ -26,6 +27,8 @@ export class WorkspaceFlatRoleTargetByAgentIdService extends WorkspaceCacheProvi
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectWorkspaceScopedRepository(RoleEntity)
private readonly roleRepository: WorkspaceScopedRepository<RoleEntity>,
@InjectWorkspaceScopedRepository(AgentEntity)
private readonly agentRepository: WorkspaceScopedRepository<AgentEntity>,
) {
super();
}
@@ -33,28 +36,36 @@ export class WorkspaceFlatRoleTargetByAgentIdService extends WorkspaceCacheProvi
async computeForCache(
workspaceId: string,
): Promise<FlatRoleTargetByAgentIdMaps> {
const [roleTargetEntities, applications, roles] = await Promise.all([
this.roleTargetRepository.find(workspaceId, {
where: {
agentId: Not(IsNull()),
},
withDeleted: true,
}),
this.applicationRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.roleRepository.find(workspaceId, {
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
]);
const [roleTargetEntities, applications, roles, agents] = await Promise.all(
[
this.roleTargetRepository.find(workspaceId, {
where: {
agentId: Not(IsNull()),
},
withDeleted: true,
}),
this.applicationRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.roleRepository.find(workspaceId, {
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.agentRepository.find(workspaceId, {
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
],
);
const applicationIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(applications);
const roleIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(roles);
const agentIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(agents);
const flatRoleTargetByAgentIdMaps: FlatRoleTargetByAgentIdMaps = {};
@@ -66,6 +77,7 @@ export class WorkspaceFlatRoleTargetByAgentIdService extends WorkspaceCacheProvi
entity: roleTargetEntity,
applicationIdToUniversalIdentifierMap,
roleIdToUniversalIdentifierMap,
agentIdToUniversalIdentifierMap,
});
flatRoleTargetByAgentIdMaps[roleTargetEntity.agentId] = flatRoleTarget;
@@ -282,7 +282,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"roleUniversalIdentifier",
"userWorkspaceId",
"apiKeyId",
"agentId",
"agentUniversalIdentifier",
],
"propertiesToStringify": [],
},
@@ -885,7 +885,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
agentId: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
universalProperty: 'agentUniversalIdentifier',
},
createdAt: {
toCompare: false,
@@ -170,6 +170,9 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
role: {
foreignKey: 'roleId',
},
agent: {
foreignKey: 'agentId',
},
apiKey: null,
workspace: null,
application: null,
@@ -303,6 +303,13 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
isNullable: false,
universalForeignKey: 'roleUniversalIdentifier',
},
agent: {
metadataName: 'agent',
foreignKey: 'agentId',
inverseOneToManyProperty: null,
isNullable: true,
universalForeignKey: 'agentUniversalIdentifier',
},
apiKey: null,
workspace: null,
application: null,
@@ -120,6 +120,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
exports[`getMetadataRelatedMetadataNames should return related metadata names for roleTarget 1`] = `
[
"role",
"agent",
]
`;
@@ -12,10 +12,10 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
"objectPermission",
"pageLayoutWidget",
"rolePermissionFlag",
"roleTarget",
"viewSort",
"index",
"pageLayout",
"roleTarget",
"rowLevelPermissionPredicateGroup",
"viewGroup",
"agent",
@@ -6,6 +6,7 @@ import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type FlatRoleTargetMaps } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target-maps.type';
import { fromRoleTargetEntityToFlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/utils/from-role-target-entity-to-flat-role-target.util';
@@ -27,12 +28,14 @@ export class WorkspaceFlatRoleTargetMapCacheService extends WorkspaceCacheProvid
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectWorkspaceScopedRepository(RoleEntity)
private readonly roleRepository: WorkspaceScopedRepository<RoleEntity>,
@InjectWorkspaceScopedRepository(AgentEntity)
private readonly agentRepository: WorkspaceScopedRepository<AgentEntity>,
) {
super();
}
async computeForCache(workspaceId: string): Promise<FlatRoleTargetMaps> {
const [roleTargets, applications, roles] = await Promise.all([
const [roleTargets, applications, roles, agents] = await Promise.all([
this.roleTargetRepository.find(workspaceId, {
withDeleted: true,
}),
@@ -45,12 +48,18 @@ export class WorkspaceFlatRoleTargetMapCacheService extends WorkspaceCacheProvid
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.agentRepository.find(workspaceId, {
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
]);
const applicationIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(applications);
const roleIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(roles);
const agentIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(agents);
const flatRoleTargetMaps = createEmptyFlatEntityMaps();
@@ -59,6 +68,7 @@ export class WorkspaceFlatRoleTargetMapCacheService extends WorkspaceCacheProvid
entity: roleTargetEntity,
applicationIdToUniversalIdentifierMap,
roleIdToUniversalIdentifierMap,
agentIdToUniversalIdentifierMap,
});
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
@@ -13,6 +13,7 @@ import {
} from 'typeorm';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@@ -50,6 +51,13 @@ export class RoleTargetEntity extends SyncableEntity {
@Column({ nullable: true, type: 'uuid' })
agentId: string | null;
@ManyToOne(() => AgentEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'agentId' })
agent: Relation<AgentEntity> | null;
@Column({ nullable: true, type: 'uuid' })
apiKeyId: string | null;
@@ -56,7 +56,12 @@ export class RoleTargetService {
return [];
}
const { flatRoleTargetMaps, flatApplicationMaps, flatRoleMaps } =
const {
flatRoleTargetMaps,
flatApplicationMaps,
flatRoleMaps,
flatAgentMaps,
} =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
@@ -64,6 +69,7 @@ export class RoleTargetService {
'flatRoleTargetMaps',
'flatApplicationMaps',
'flatRoleMaps',
'flatAgentMaps',
],
},
);
@@ -88,6 +94,7 @@ export class RoleTargetService {
createRoleTargetInput,
flatRoleTargetMaps,
flatRoleMaps,
flatAgentMaps,
workspaceId,
flatApplication: flatApplication ?? workspaceCustomFlatApplication,
});
@@ -3,6 +3,7 @@ import { v4 } from 'uuid';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { type FlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target.type';
import { findFlatRoleTargetFromForeignKey } from 'src/engine/metadata-modules/flat-role-target/utils/find-flat-role-target-from-foreign-key.util';
@@ -13,12 +14,16 @@ export const fromCreateRoleTargetInputToFlatRoleTargetToCreate = ({
workspaceId,
flatRoleTargetMaps,
flatRoleMaps,
flatAgentMaps,
flatApplication,
}: {
createRoleTargetInput: CreateRoleTargetInput;
workspaceId: string;
flatApplication: FlatApplication;
} & Pick<AllFlatEntityMaps, 'flatRoleTargetMaps' | 'flatRoleMaps'>): {
} & Pick<
AllFlatEntityMaps,
'flatRoleTargetMaps' | 'flatRoleMaps' | 'flatAgentMaps'
>): {
flatRoleTargetToCreate: FlatRoleTarget;
flatRoleTargetsToDelete: FlatRoleTarget[];
} => {
@@ -34,12 +39,21 @@ export const fromCreateRoleTargetInputToFlatRoleTargetToCreate = ({
},
);
const agentUniversalIdentifier =
targetMetadataForeignKey === 'agentId'
? findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatAgentMaps,
flatEntityId: targetId,
}).universalIdentifier
: null;
const flatRoleTargetToCreate: FlatRoleTarget = {
id: v4(),
roleId,
roleUniversalIdentifier,
userWorkspaceId: null,
agentId: null,
agentUniversalIdentifier,
apiKeyId: null,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
@@ -7,6 +7,7 @@ import { ApplicationModule } from 'src/engine/core-modules/application/applicati
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module';
import { FlatAgentModule } from 'src/engine/metadata-modules/flat-agent/flat-agent.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@@ -37,6 +38,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
imports: [
TypeOrmModule.forFeature([
ApplicationEntity,
AgentEntity,
RoleEntity,
RoleTargetEntity,
ObjectPermissionEntity,
@@ -72,6 +74,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceRolesPermissionsCacheService,
provideWorkspaceScopedRepository(RoleEntity),
provideWorkspaceScopedRepository(RoleTargetEntity),
provideWorkspaceScopedRepository(AgentEntity),
provideWorkspaceScopedRepository(ObjectPermissionEntity),
provideWorkspaceScopedRepository(FieldPermissionEntity),
provideWorkspaceScopedRepository(RowLevelPermissionPredicateEntity),