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:
+75
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+6
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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
|
||||
);
|
||||
});
|
||||
};
|
||||
+189
-2
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+66
-16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user