ToolJet/server/test/modules/group-permissions/unit/feature-ability.spec.ts
Shantanu Mane 89448cf492
Feature: Group Admin (#16210)
* fix: add FK constraints, unique constraint, and down() to group_admins migration

* fix: add explicit FK constraint names to group_admins migration

* feat: add GroupAdmin TypeORM entity

* fix: add Organization relation to GroupAdmin entity

* feat: add group-admin FEATURE_KEY entries and feature configs

* feat: add CE no-op stub for GroupAdminService

* feat: register GroupAdminService/Controller in module; add group-admin controller (CE stub + EE impl)

* feat: pass user to getAllGroup(); update interface and CE service signature

* test: add e2e tests for group-admin assign, revoke, scoped access, and auto-revocation

* fix: correct imports and tsconfig for group-admin e2e test

- fix entity imports: use @entities alias instead of src/entities
- fix module imports: use @modules alias instead of src/modules
- remove unused findEntityOrFail import from test-helper
- add jest to tsconfig types array for test type definitions
- add test-helper path mapping to tsconfig for proper resolution

* fix: register GroupAdmin entity in TypeOrmModule.forFeature for DI

* fix: grant workspace admins all group-admin feature keys in ability factory

* feat: add group-admin service methods

Add getGroupAdmins, getAddableAdmins, assignGroupAdmin, revokeGroupAdmin methods to groupPermissionV2Service following existing fetch pattern.

* feat: allow group-admin builders to access Groups link in workspace settings

Add is_group_admin and canManageGroups to conditionObj (both useState initializer
and subscribe handler). Change Groups link condition from ['admin'] to
['canManageGroups'] to allow admin OR group-admin builders.

* feat: add Group Admins tab to group permission resources

* fix: handle missing default groups for group-admin builders; hide admin-only controls

* feat: make permissions and granular access tabs read-only for group-admin builders; hide role-change in users tab

* feat: implement GroupAdminOrAdminRoute for session validation; enhance group admin management features

* feat: add GET_USER_ADMIN_GROUPS feature and update related permissions; enhance user group management

* feat: enhance group admin functionality; add builder role and improve admin assignment tests

* Fix: Add compiler options to tsconfig.build.json

* refactor: revert configs for test suite

* feat: enhance group admin functionality with feature access checks and user removal permissions

* feat: add option to duplicate group admins in duplicate group

* feat: enhance group permissions management

- Updated FeatureAbilityFactory to include additional checks for builder permissions and group-specific access.
- Modified GroupPermissionsController to apply FeatureAbilityGuard for various endpoints, ensuring proper permission checks.
- Enhanced GranularPermissionsController with appropriate guards for better access control.
- Refactored GroupExistenceGuard to improve group validation logic and error handling.
- Updated GroupPermissionsModule to streamline service and utility registrations.
- Added unit tests for FeatureAbilityFactory and GroupExistenceGuard to ensure robust permission handling.

* feat: implement afterUpdateUserRole method in RolesService

* feat: refactor GroupPermissionsUtilService usage in GroupPermissionsModule

* feat: enhance group permissions copyright and icons, minor bug fixes:

* Bug fixes

Co-authored-by: Copilot <copilot@github.com>

* bugz

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Rudhra Deep Biswas <rudra21ultra@gmail.com>
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 19:29:03 +05:30

287 lines
11 KiB
TypeScript

import { Ability, AbilityBuilder, AbilityClass } from '@casl/ability';
import { FeatureAbilityFactory, FeatureAbility } from '@modules/group-permissions/ability';
import { AbilityService } from '@modules/ability/interfaces/IService';
import { FEATURE_KEY, GROUP_PERMISSIONS_TYPE } from '@modules/group-permissions/constants';
import { GroupPermissions } from '@entities/group_permissions.entity';
const makeBuilder = () => new AbilityBuilder<FeatureAbility>(Ability as AbilityClass<FeatureAbility>);
// All features granted to admins/superAdmins
const ALL_ADMIN_FEATURES = [
FEATURE_KEY.ADD_GROUP_USER,
FEATURE_KEY.CREATE,
FEATURE_KEY.DELETE,
FEATURE_KEY.DELETE_GROUP_USER,
FEATURE_KEY.DUPLICATE,
FEATURE_KEY.GET_ADDABLE_USERS,
FEATURE_KEY.GET_ONE,
FEATURE_KEY.GET_ALL,
FEATURE_KEY.UPDATE,
FEATURE_KEY.GET_ALL_GROUP_USER,
FEATURE_KEY.DELETE_GRANULAR_APP_PERMISSIONS,
FEATURE_KEY.DELETE_GRANULAR_DATA_PERMISSIONS,
FEATURE_KEY.CREATE_GRANULAR_APP_PERMISSIONS,
FEATURE_KEY.CREATE_GRANULAR_DATA_PERMISSIONS,
FEATURE_KEY.GET_ALL_GRANULAR_PERMISSIONS,
FEATURE_KEY.GET_ADDABLE_APPS,
FEATURE_KEY.UPDATE_GRANULAR_APP_PERMISSIONS,
FEATURE_KEY.UPDATE_GRANULAR_DATA_PERMISSIONS,
FEATURE_KEY.GET_ADDABLE_DS,
FEATURE_KEY.USER_ROLE_CHANGE,
FEATURE_KEY.CREATE_GRANULAR_FOLDER_PERMISSIONS,
FEATURE_KEY.UPDATE_GRANULAR_FOLDER_PERMISSIONS,
FEATURE_KEY.DELETE_GRANULAR_FOLDER_PERMISSIONS,
FEATURE_KEY.GET_ADDABLE_FOLDERS,
FEATURE_KEY.ASSIGN_GROUP_ADMIN,
FEATURE_KEY.REVOKE_GROUP_ADMIN,
FEATURE_KEY.GET_GROUP_ADMINS,
FEATURE_KEY.GET_ADDABLE_ADMINS,
FEATURE_KEY.GET_USER_ADMIN_GROUPS,
];
// Features a group-admin builder gets regardless of which group is requested
const BUILDER_LIST_FEATURES = [
FEATURE_KEY.GET_ALL,
FEATURE_KEY.GET_ADDABLE_APPS,
FEATURE_KEY.GET_ADDABLE_DS,
FEATURE_KEY.GET_ADDABLE_FOLDERS,
FEATURE_KEY.GET_USER_ADMIN_GROUPS,
];
// Features a group-admin builder gets on their own administered custom group
const BUILDER_ADMIN_GROUP_FEATURES = [
FEATURE_KEY.GET_ONE,
FEATURE_KEY.ADD_GROUP_USER,
FEATURE_KEY.DELETE_GROUP_USER,
FEATURE_KEY.GET_ADDABLE_USERS,
FEATURE_KEY.GET_ALL_GROUP_USER,
FEATURE_KEY.GET_GROUP_ADMINS,
FEATURE_KEY.GET_ALL_GRANULAR_PERMISSIONS,
];
// Features that builders must NEVER get (admin-escalation guard)
const BUILDER_BLOCKED_FEATURES = [
FEATURE_KEY.CREATE,
FEATURE_KEY.UPDATE,
FEATURE_KEY.DELETE,
FEATURE_KEY.DUPLICATE,
FEATURE_KEY.ASSIGN_GROUP_ADMIN,
FEATURE_KEY.REVOKE_GROUP_ADMIN,
FEATURE_KEY.GET_ADDABLE_ADMINS,
FEATURE_KEY.USER_ROLE_CHANGE,
FEATURE_KEY.CREATE_GRANULAR_APP_PERMISSIONS,
FEATURE_KEY.CREATE_GRANULAR_DATA_PERMISSIONS,
FEATURE_KEY.CREATE_GRANULAR_FOLDER_PERMISSIONS,
FEATURE_KEY.UPDATE_GRANULAR_APP_PERMISSIONS,
FEATURE_KEY.UPDATE_GRANULAR_DATA_PERMISSIONS,
FEATURE_KEY.UPDATE_GRANULAR_FOLDER_PERMISSIONS,
FEATURE_KEY.DELETE_GRANULAR_APP_PERMISSIONS,
FEATURE_KEY.DELETE_GRANULAR_DATA_PERMISSIONS,
FEATURE_KEY.DELETE_GRANULAR_FOLDER_PERMISSIONS,
];
describe('FeatureAbilityFactory :: group permissions', () => {
const factory = new FeatureAbilityFactory({} as AbilityService);
async function build(perms: Partial<typeof basePerms>, request?: any) {
const { can, build: buildAbility } = makeBuilder();
await (factory as any).defineAbilityFor(can, { ...basePerms, ...perms }, undefined, request);
return buildAbility();
}
const basePerms = {
superAdmin: false,
isAdmin: false,
isBuilder: false,
isEndUser: false,
user: { id: 'user-1', organizationId: 'org-1' },
userPermission: {} as any,
resource: [],
};
// ---------------------------------------------------------------------------
// End-users
// ---------------------------------------------------------------------------
describe('end-user', () => {
it('gets no group-permissions features whatsoever', async () => {
const ability = await build({ isEndUser: true });
for (const feature of ALL_ADMIN_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(false);
}
});
});
// ---------------------------------------------------------------------------
// Admin
// ---------------------------------------------------------------------------
describe('admin', () => {
it('gets all features', async () => {
const ability = await build({ isAdmin: true });
for (const feature of ALL_ADMIN_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(true);
}
});
});
// ---------------------------------------------------------------------------
// SuperAdmin
// ---------------------------------------------------------------------------
describe('superAdmin', () => {
it('gets all features', async () => {
const ability = await build({ superAdmin: true });
for (const feature of ALL_ADMIN_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(true);
}
});
});
// ---------------------------------------------------------------------------
// Builder with NO group-admin assignments
// ---------------------------------------------------------------------------
describe('builder — no group-admin assignments', () => {
it('gets no features when tj_admin_groups is missing', async () => {
const ability = await build({ isBuilder: true }, { params: {} });
for (const feature of ALL_ADMIN_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(false);
}
});
it('gets no features when tj_admin_groups is empty array', async () => {
const ability = await build({ isBuilder: true }, { tj_admin_groups: [], params: {} });
for (const feature of ALL_ADMIN_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(false);
}
});
});
// ---------------------------------------------------------------------------
// Builder-admin — list-level access (no specific group requested)
// ---------------------------------------------------------------------------
describe('builder-admin — no group context (list-level)', () => {
const request = {
tj_admin_groups: [{ id: 'group-1', name: 'ops' }],
params: {},
};
it('grants list-level features', async () => {
const ability = await build({ isBuilder: true }, request);
for (const feature of BUILDER_LIST_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(true);
}
});
it('does not grant any group-specific or write features', async () => {
const ability = await build({ isBuilder: true }, request);
for (const feature of BUILDER_BLOCKED_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(false);
}
expect(ability.can(FEATURE_KEY.GET_ONE, GroupPermissions)).toBe(false);
expect(ability.can(FEATURE_KEY.ADD_GROUP_USER, GroupPermissions)).toBe(false);
expect(ability.can(FEATURE_KEY.DELETE_GROUP_USER, GroupPermissions)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Builder-admin — administered custom group
// ---------------------------------------------------------------------------
describe('builder-admin — their own administered custom group', () => {
const request = {
tj_admin_groups: [{ id: 'group-1', name: 'ops' }],
tj_group: { id: 'group-1', type: GROUP_PERMISSIONS_TYPE.CUSTOM_GROUP },
tj_resource_id: 'group-1',
params: {},
};
it('grants user-management features on the administered group', async () => {
const ability = await build({ isBuilder: true }, request);
for (const feature of BUILDER_ADMIN_GROUP_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(true);
}
});
it('also retains list-level features', async () => {
const ability = await build({ isBuilder: true }, request);
for (const feature of BUILDER_LIST_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(true);
}
});
it('never grants write/destructive or admin-escalation features', async () => {
const ability = await build({ isBuilder: true }, request);
for (const feature of BUILDER_BLOCKED_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(false);
}
});
});
// ---------------------------------------------------------------------------
// Builder-admin — custom group they do NOT administer
// ---------------------------------------------------------------------------
describe('builder-admin — custom group they do not administer', () => {
const request = {
tj_admin_groups: [{ id: 'group-1', name: 'ops' }],
tj_group: { id: 'group-99', type: GROUP_PERMISSIONS_TYPE.CUSTOM_GROUP },
tj_resource_id: 'group-99',
params: {},
};
it('gets only list-level features, no group-specific access', async () => {
const ability = await build({ isBuilder: true }, request);
for (const feature of BUILDER_LIST_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(true);
}
expect(ability.can(FEATURE_KEY.GET_ONE, GroupPermissions)).toBe(false);
expect(ability.can(FEATURE_KEY.ADD_GROUP_USER, GroupPermissions)).toBe(false);
expect(ability.can(FEATURE_KEY.DELETE_GROUP_USER, GroupPermissions)).toBe(false);
expect(ability.can(FEATURE_KEY.GET_ALL_GROUP_USER, GroupPermissions)).toBe(false);
});
it('never grants write or admin-escalation features', async () => {
const ability = await build({ isBuilder: true }, request);
for (const feature of BUILDER_BLOCKED_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(false);
}
});
});
// ---------------------------------------------------------------------------
// Builder-admin — default group (read-only)
// ---------------------------------------------------------------------------
describe('builder-admin — default group (not administered)', () => {
const request = {
tj_admin_groups: [{ id: 'group-1', name: 'ops' }],
tj_group: { id: 'default-group', type: GROUP_PERMISSIONS_TYPE.DEFAULT },
tj_resource_id: 'default-group',
params: {},
};
it('can read group details and list users/granular-permissions', async () => {
const ability = await build({ isBuilder: true }, request);
expect(ability.can(FEATURE_KEY.GET_ONE, GroupPermissions)).toBe(true);
expect(ability.can(FEATURE_KEY.GET_ALL_GROUP_USER, GroupPermissions)).toBe(true);
expect(ability.can(FEATURE_KEY.GET_ALL_GRANULAR_PERMISSIONS, GroupPermissions)).toBe(true);
});
it('cannot mutate users on a default group', async () => {
const ability = await build({ isBuilder: true }, request);
expect(ability.can(FEATURE_KEY.ADD_GROUP_USER, GroupPermissions)).toBe(false);
expect(ability.can(FEATURE_KEY.DELETE_GROUP_USER, GroupPermissions)).toBe(false);
expect(ability.can(FEATURE_KEY.GET_ADDABLE_USERS, GroupPermissions)).toBe(false);
});
it('never grants write or admin-escalation features', async () => {
const ability = await build({ isBuilder: true }, request);
for (const feature of BUILDER_BLOCKED_FEATURES) {
expect(ability.can(feature, GroupPermissions)).toBe(false);
}
});
});
});