perf: deduplicate tool permission role loads (#23366)

## Context

Building the tool catalog asks every provider whether it is available
for the current role configuration. Several providers perform multiple
permission checks, so one catalog build can evaluate the same roles
repeatedly.

Previously, each `checkRolesPermissions` or `hasToolPermission` call
loaded the configured roles and their permission flags from PostgreSQL.
These queries returned data that already exists in the workspace cache:

- `flatRoleMaps` contains role settings and the IDs of assigned
permission flags
- `flatRolePermissionFlagMaps` links those assignments to permission
flag universal identifiers

This created redundant database round trips on the latency-sensitive
tool discovery path.

## What changed

Permission checks now evaluate roles from the existing workspace cache
instead of loading `RoleEntity` records and relations from PostgreSQL.

The new flow:

1. Load `flatRoleMaps` and `flatRolePermissionFlagMaps` through
`WorkspaceCacheService`
2. Resolve every role ID from `flatRoleMaps`
3. Check `canAccessAllTools` or `canUpdateAllSettings`
4. If needed, check explicit permission flags with
`flatRoleHasPermissionFlag`
5. Apply the existing union or intersection rule

This also benefits callers outside the tool registry, without adding
provider parameters or request-scoped context plumbing.

The direct agent-only role deletion path now invalidates and recomputes
the two consumed cache maps after deleting a role. This prevents that
path from leaving stale permission data behind.

## Why this is safe

The authorization behavior remains unchanged:

- `shouldBypassPermissionChecks` still grants access without loading
permission data
- A union grants access when at least one role grants it
- An intersection grants access only when every role grants it
- Base role permissions and explicitly assigned permission flags are
both supported
- Empty, duplicate, or missing role IDs fail closed
- Cache failures fail closed

This PR does not introduce a separate permission cache. It reuses the
existing workspace metadata cache and its invalidation model.

## Expected impact

On a warm workspace cache, these permission checks no longer query the
role tables. Repeated checks during catalog and schema construction
become in-memory cache lookups, reducing database pressure and avoiding
repeated network round trips.

A cold cache can still require its normal database recomputation.
Subsequent permission checks reuse the populated workspace cache.

## Test coverage

The permission service tests cover:

- Union and intersection behavior
- Base role grants
- Explicit permission flag grants
- Unrelated permission flags
- Permission bypass
- Empty, duplicate, and missing roles
- Cache failures
- No role repository query during cached evaluation

The agent-role tests also verify that deleting an unused agent-only role
refreshes the relevant cache maps, while a role that remains assigned
does not trigger deletion or invalidation.
This commit is contained in:
Weiko
2026-07-27 18:32:54 +02:00
committed by GitHub
parent 48f4c1661b
commit a66aacfb82
6 changed files with 374 additions and 18 deletions
@@ -12,6 +12,7 @@ import { RoleTargetService } from 'src/engine/metadata-modules/role-target/servi
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
import { type WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { AiAgentRoleService } from './ai-agent-role.service';
describe('AiAgentRoleService', () => {
@@ -20,6 +21,7 @@ describe('AiAgentRoleService', () => {
let roleRepository: WorkspaceScopedRepository<RoleEntity>;
let roleTargetRepository: WorkspaceScopedRepository<RoleTargetEntity>;
let roleTargetService: RoleTargetService;
let workspaceCacheService: WorkspaceCacheService;
const testWorkspaceId = 'test-workspace-id';
let testAgent: AgentEntity;
@@ -43,6 +45,7 @@ describe('AiAgentRoleService', () => {
useValue: {
findOne: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
},
},
{
@@ -62,6 +65,12 @@ describe('AiAgentRoleService', () => {
delete: jest.fn(),
},
},
{
provide: WorkspaceCacheService,
useValue: {
invalidateAndRecompute: jest.fn(),
},
},
],
}).compile();
@@ -76,6 +85,9 @@ describe('AiAgentRoleService', () => {
WorkspaceScopedRepository<RoleTargetEntity>
>(getWorkspaceScopedRepositoryToken(RoleTargetEntity));
roleTargetService = module.get<RoleTargetService>(RoleTargetService);
workspaceCacheService = module.get<WorkspaceCacheService>(
WorkspaceCacheService,
);
// Setup test data
testAgent = {
@@ -372,4 +384,67 @@ describe('AiAgentRoleService', () => {
});
});
});
describe('deleteAgentOnlyRoleIfUnused', () => {
it('invalidates cached role permissions after deleting the role', async () => {
const agentOnlyRole = {
...testRole,
canBeAssignedToAgents: true,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
} as RoleEntity;
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(agentOnlyRole);
jest.spyOn(roleTargetRepository, 'count').mockResolvedValue(0);
jest
.spyOn(roleRepository, 'delete')
.mockResolvedValue({ raw: [], generatedMaps: [] });
jest
.spyOn(workspaceCacheService, 'invalidateAndRecompute')
.mockResolvedValue();
await service.deleteAgentOnlyRoleIfUnused({
roleId: agentOnlyRole.id,
roleTargetId: 'deleted-role-target-id',
workspaceId: testWorkspaceId,
});
expect(roleRepository.delete).toHaveBeenCalledWith(testWorkspaceId, {
id: agentOnlyRole.id,
});
expect(workspaceCacheService.invalidateAndRecompute).toHaveBeenCalledWith(
testWorkspaceId,
['flatRoleMaps', 'flatRolePermissionFlagMaps'],
);
expect(
(roleRepository.delete as jest.Mock).mock.invocationCallOrder[0],
).toBeLessThan(
(workspaceCacheService.invalidateAndRecompute as jest.Mock).mock
.invocationCallOrder[0],
);
});
it('keeps the cache unchanged while the role is still assigned', async () => {
const agentOnlyRole = {
...testRole,
canBeAssignedToAgents: true,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
} as RoleEntity;
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(agentOnlyRole);
jest.spyOn(roleTargetRepository, 'count').mockResolvedValue(1);
await service.deleteAgentOnlyRoleIfUnused({
roleId: agentOnlyRole.id,
roleTargetId: 'deleted-role-target-id',
workspaceId: testWorkspaceId,
});
expect(roleRepository.delete).not.toHaveBeenCalled();
expect(
workspaceCacheService.invalidateAndRecompute,
).not.toHaveBeenCalled();
});
});
});
@@ -6,12 +6,14 @@ import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-t
import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { AiAgentRoleService } from './ai-agent-role.service';
@Module({
imports: [
TypeOrmModule.forFeature([AgentEntity, RoleEntity, RoleTargetEntity]),
RoleTargetModule,
WorkspaceCacheModule,
],
providers: [
AiAgentRoleService,
@@ -13,6 +13,7 @@ import { RoleTargetService } from 'src/engine/metadata-modules/role-target/servi
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class AiAgentRoleService {
constructor(
@@ -23,6 +24,7 @@ export class AiAgentRoleService {
@InjectWorkspaceScopedRepository(RoleTargetEntity)
private readonly roleTargetRepository: WorkspaceScopedRepository<RoleTargetEntity>,
private readonly roleTargetService: RoleTargetService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
public async assignRoleToAgent({
@@ -196,6 +198,10 @@ export class AiAgentRoleService {
if (remainingAssignments === 0) {
await this.roleRepository.delete(workspaceId, { id: roleId });
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatRoleMaps',
'flatRolePermissionFlagMaps',
]);
}
}
}
@@ -0,0 +1,36 @@
import {
type PermissionFlagType,
SystemPermissionFlag,
} from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { type FlatRolePermissionFlagMaps } from 'src/engine/metadata-modules/flat-role-permission-flag/types/flat-role-permission-flag-maps.type';
import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type';
export const flatRoleHasPermissionFlag = ({
flatRole,
permissionFlag,
flatRolePermissionFlagMaps,
}: {
flatRole: FlatRole;
permissionFlag: PermissionFlagType;
flatRolePermissionFlagMaps: FlatRolePermissionFlagMaps;
}): boolean => {
const permissionFlagUniversalIdentifier =
SystemPermissionFlag[permissionFlag];
return flatRole.rolePermissionFlagIds.some((rolePermissionFlagId) => {
const rolePermissionFlagUniversalIdentifier =
flatRolePermissionFlagMaps.universalIdentifierById[rolePermissionFlagId];
if (!isDefined(rolePermissionFlagUniversalIdentifier)) {
return false;
}
return (
flatRolePermissionFlagMaps.byUniversalIdentifier[
rolePermissionFlagUniversalIdentifier
]?.permissionFlagUniversalIdentifier === permissionFlagUniversalIdentifier
);
});
};
@@ -8,22 +8,50 @@ import {
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type SyncableFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { type FlatRolePermissionFlag } from 'src/engine/metadata-modules/flat-role-permission-flag/types/flat-role-permission-flag.type';
import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const buildFlatEntityMaps = <T extends SyncableFlatEntity>(
entities: T[],
): FlatEntityMaps<T> =>
entities.reduce(
(maps, entity) =>
addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: entity,
flatEntityMaps: maps,
}),
createEmptyFlatEntityMaps() as FlatEntityMaps<T>,
);
describe('PermissionsService', () => {
let service: PermissionsService;
let roleRepository: { find: jest.Mock };
let workspaceCacheService: { getOrRecompute: jest.Mock };
beforeEach(async () => {
roleRepository = {
find: jest.fn(),
};
workspaceCacheService = {
getOrRecompute: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
PermissionsService,
{
provide: getWorkspaceScopedRepositoryToken(RoleEntity),
useValue: {},
useValue: roleRepository,
},
{
provide: ApiKeyRoleService,
@@ -35,7 +63,7 @@ describe('PermissionsService', () => {
},
{
provide: WorkspaceCacheService,
useValue: {},
useValue: workspaceCacheService,
},
{
provide: getRepositoryToken(ApplicationEntity),
@@ -450,4 +478,163 @@ describe('PermissionsService', () => {
});
});
});
describe.each([
{
evaluator: 'checkRolesPermissions' as const,
permissionFlag: PermissionFlagType.DATA_MODEL,
basePermission: 'canUpdateAllSettings' as const,
},
{
evaluator: 'hasToolPermission' as const,
permissionFlag: PermissionFlagType.HTTP_REQUEST_TOOL,
basePermission: 'canAccessAllTools' as const,
},
])(
'$evaluator from workspace cache',
({ evaluator, permissionFlag, basePermission }) => {
const workspaceId = 'test-workspace-id';
const createFlatRole = ({
id,
hasBasePermission = false,
rolePermissionFlagIds = [],
}: {
id: string;
hasBasePermission?: boolean;
rolePermissionFlagIds?: string[];
}): FlatRole =>
({
id,
universalIdentifier: `${id}-universal-identifier`,
canAccessAllTools: false,
canUpdateAllSettings: false,
rolePermissionFlagIds,
[basePermission]: hasBasePermission,
}) as FlatRole;
const createFlatRolePermissionFlag = ({
id,
permissionFlagUniversalIdentifier,
}: {
id: string;
permissionFlagUniversalIdentifier: string;
}): FlatRolePermissionFlag =>
({
id,
universalIdentifier: `${id}-universal-identifier`,
permissionFlagUniversalIdentifier,
}) as FlatRolePermissionFlag;
const mockCachedPermissions = ({
roles,
rolePermissionFlags = [],
}: {
roles: FlatRole[];
rolePermissionFlags?: FlatRolePermissionFlag[];
}) => {
workspaceCacheService.getOrRecompute.mockResolvedValue({
flatRoleMaps: buildFlatEntityMaps(roles),
flatRolePermissionFlagMaps: buildFlatEntityMaps(rolePermissionFlags),
});
};
const evaluate = (rolePermissionConfig: RolePermissionConfig) =>
service[evaluator](rolePermissionConfig, workspaceId, permissionFlag);
afterEach(() => {
expect(roleRepository.find).not.toHaveBeenCalled();
});
it('preserves union and intersection semantics', async () => {
const grantingRole = createFlatRole({
id: 'granting-role-id',
hasBasePermission: true,
});
const denyingRole = createFlatRole({
id: 'denying-role-id',
});
mockCachedPermissions({
roles: [grantingRole, denyingRole],
});
await expect(
evaluate({ unionOf: [grantingRole.id, denyingRole.id] }),
).resolves.toBe(true);
await expect(
evaluate({ intersectionOf: [grantingRole.id, denyingRole.id] }),
).resolves.toBe(false);
expect(workspaceCacheService.getOrRecompute).toHaveBeenCalledWith(
workspaceId,
['flatRoleMaps', 'flatRolePermissionFlagMaps'],
);
});
it('grants an explicitly assigned permission flag', async () => {
const rolePermissionFlag = createFlatRolePermissionFlag({
id: 'role-permission-flag-id',
permissionFlagUniversalIdentifier:
SystemPermissionFlag[permissionFlag],
});
const role = createFlatRole({
id: 'role-id',
rolePermissionFlagIds: [rolePermissionFlag.id],
});
mockCachedPermissions({
roles: [role],
rolePermissionFlags: [rolePermissionFlag],
});
await expect(evaluate({ unionOf: [role.id] })).resolves.toBe(true);
});
it('denies an unrelated permission flag', async () => {
const rolePermissionFlag = createFlatRolePermissionFlag({
id: 'role-permission-flag-id',
permissionFlagUniversalIdentifier: SystemPermissionFlag.WORKSPACE,
});
const role = createFlatRole({
id: 'role-id',
rolePermissionFlagIds: [rolePermissionFlag.id],
});
mockCachedPermissions({
roles: [role],
rolePermissionFlags: [rolePermissionFlag],
});
await expect(evaluate({ unionOf: [role.id] })).resolves.toBe(false);
});
it('bypasses checks without loading the cache', async () => {
await expect(
evaluate({ shouldBypassPermissionChecks: true }),
).resolves.toBe(true);
expect(workspaceCacheService.getOrRecompute).not.toHaveBeenCalled();
});
it('fails closed for invalid or missing roles', async () => {
const role = createFlatRole({
id: 'role-id',
hasBasePermission: true,
});
mockCachedPermissions({ roles: [role] });
await expect(evaluate({ unionOf: [] })).resolves.toBe(false);
await expect(evaluate({ unionOf: [role.id, role.id] })).resolves.toBe(
false,
);
await expect(
evaluate({ unionOf: [role.id, 'missing-role-id'] }),
).resolves.toBe(false);
});
it('fails closed when the workspace cache is unavailable', async () => {
workspaceCacheService.getOrRecompute.mockRejectedValue(
new Error('Cache unavailable'),
);
await expect(evaluate({ unionOf: ['role-id'] })).resolves.toBe(false);
});
},
);
});
@@ -7,7 +7,7 @@ import {
SystemPermissionFlag,
} from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -15,6 +15,9 @@ import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { type FlatRolePermissionFlagMaps } from 'src/engine/metadata-modules/flat-role-permission-flag/types/flat-role-permission-flag-maps.type';
import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type';
import { flatRoleHasPermissionFlag } from 'src/engine/metadata-modules/flat-role/utils/flat-role-has-permission-flag.util';
import { TOOL_PERMISSION_FLAGS } from 'src/engine/metadata-modules/permissions/constants/tool-permission-flags';
import {
PermissionsException,
@@ -29,6 +32,12 @@ import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
type CachedRolesFromPermissionConfig = {
roles: FlatRole[];
useIntersection: boolean;
flatRolePermissionFlagMaps: FlatRolePermissionFlagMaps;
} | null;
@Injectable()
export class PermissionsService {
constructor(
@@ -271,8 +280,7 @@ export class PermissionsService {
private async getRolesFromPermissionConfig(
rolePermissionConfig: RolePermissionConfig,
workspaceId: string,
relations: string[] = [],
): Promise<{ roles: RoleEntity[]; useIntersection: boolean } | null> {
): Promise<CachedRolesFromPermissionConfig> {
if ('shouldBypassPermissionChecks' in rolePermissionConfig) {
return null;
}
@@ -292,16 +300,50 @@ export class PermissionsService {
throw new Error('No role IDs provided');
}
const roles = await this.roleRepository.find(workspaceId, {
where: { id: In(roleIds) },
relations,
});
if (new Set(roleIds).size !== roleIds.length) {
throw new Error('Duplicate role IDs provided');
}
const { flatRoleMaps, flatRolePermissionFlagMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatRoleMaps',
'flatRolePermissionFlagMaps',
]);
const roles = roleIds
.map((roleId) => {
const roleUniversalIdentifier =
flatRoleMaps.universalIdentifierById[roleId];
return isDefined(roleUniversalIdentifier)
? flatRoleMaps.byUniversalIdentifier[roleUniversalIdentifier]
: undefined;
})
.filter(isDefined);
if (roles.length !== roleIds.length) {
throw new Error('Some roles not found');
}
return { roles, useIntersection };
return { roles, useIntersection, flatRolePermissionFlagMaps };
}
private checkFlatRolePermissions(
role: FlatRole,
setting: PermissionFlagType,
flatRolePermissionFlagMaps: FlatRolePermissionFlagMaps,
): boolean {
const hasBasePermission = this.isToolPermission(setting)
? role.canAccessAllTools
: role.canUpdateAllSettings;
return (
hasBasePermission === true ||
flatRoleHasPermissionFlag({
flatRole: role,
permissionFlag: setting,
flatRolePermissionFlagMaps,
})
);
}
public async checkRolesPermissions(
@@ -313,18 +355,23 @@ export class PermissionsService {
const result = await this.getRolesFromPermissionConfig(
rolePermissionConfig,
workspaceId,
['rolePermissionFlags', 'rolePermissionFlags.permissionFlag'],
);
if (result === null) {
return true;
}
const { roles, useIntersection } = result;
const { roles, useIntersection, flatRolePermissionFlagMaps } = result;
const checkRoleHasPermission = (role: FlatRole) =>
this.checkFlatRolePermissions(
role,
setting,
flatRolePermissionFlagMaps,
);
return useIntersection
? roles.every((role) => this.checkRolePermissions(role, setting))
: roles.some((role) => this.checkRolePermissions(role, setting));
? roles.every(checkRoleHasPermission)
: roles.some(checkRoleHasPermission);
} catch {
return false;
}
@@ -339,21 +386,24 @@ export class PermissionsService {
const result = await this.getRolesFromPermissionConfig(
rolePermissionConfig,
workspaceId,
['rolePermissionFlags', 'rolePermissionFlags.permissionFlag'],
);
if (result === null) {
return true;
}
const { roles, useIntersection } = result;
const { roles, useIntersection, flatRolePermissionFlagMaps } = result;
const checkRoleHasPermission = (role: RoleEntity) => {
const checkRoleHasPermission = (role: FlatRole) => {
if (role.canAccessAllTools === true) {
return true;
}
return this.roleHasPermissionFlag(role, flag);
return flatRoleHasPermissionFlag({
flatRole: role,
permissionFlag: flag,
flatRolePermissionFlagMaps,
});
};
return useIntersection