User Groups
Group-based role and restriction assignment with tenant scoping, member management, and additive merge at login.
User Groups provide a scalable way to assign roles and field-level restrictions to large numbers of administrators. Instead of assigning roles individually to each admin, you create a group, add roles and restrictions to it, then add admins as members. All members automatically inherit the group's roles and restrictions on their next login.
Architecture
Each UserGroup belongs to a tenant and has junction links: AdminUserGroup for members, UserGroupRole for roles, and UserGroupRestriction for field-level security. Relations are configured with Cascade Delete on the UserGroup side, so deleting a group automatically removes member links, roles, and restrictions, but Restrict Delete on the Tenant side prevents removing a Tenant with active groups.
Domain Model & Configurations
The User Groups feature utilizes four domain entities: UserGroup (aggregate root), AdminUserGroup (many-to-many junction), UserGroupRole (many-to-many junction), and UserGroupRestriction (field restrictions). Database configurations enforce unique composite indexes on {TenantId, Code} for UserGroup, {AdminId, UserGroupId} for AdminUserGroup, and {UserGroupId, RoleId} for UserGroupRole to prevent duplicate mappings.
| Entity | Purpose | Database Table | Keys & Composite Indexes | Cascade Deletion Rule |
|---|---|---|---|---|
| UserGroup | Belongs to Tenant (or null for global groups). Group names are localizable. | UserGroups | PK: Id, Unique Composite: { TenantId, Code }, Indexes: TenantId, IsActive, IsDeleted | Tenant: Restrict (cannot delete Tenant if active groups exist). |
| AdminUserGroup | Links an Admin to a UserGroup (enrollment junction). | AdminUserGroups | PK: Id, Unique Composite: { AdminId, UserGroupId }, Indexes: AdminId, UserGroupId, IsDeleted | Admin: Cascade, UserGroup: Cascade (junction deleted if either side is removed). |
| UserGroupRole | Maps group memberships to RBAC Roles. | UserGroupRoles | PK: Id, Unique Composite: { UserGroupId, RoleId }, Indexes: UserGroupId, RoleId, IsDeleted | UserGroup: Cascade, Role: Restrict (cannot delete Role if mapped to active groups). |
| UserGroupRestriction | Defines group-level field restrictions per permission code. | UserGroupRestrictions | PK: Id, Indexes: UserGroupId, IsDeleted | UserGroup: Cascade (restrictions deleted when the group is deleted). |
public class UserGroup : AuditableEntity<Guid>
{
[Required]
[MaxLength(100)]
public string NameEn { get; set; } = string.Empty;
[Required]
[MaxLength(100)]
public string NameAr { get; set; } = string.Empty;
[Required]
[MaxLength(50)]
public string Code { get; set; } = string.Empty;
[MaxLength(500)]
public string? DescriptionEn { get; set; }
[MaxLength(500)]
public string? DescriptionAr { get; set; }
public Guid? TenantId { get; set; }
public new bool IsActive { get; set; } = true;
// Navigation Properties
public virtual Tenant? Tenant { get; set; }
public virtual ICollection<AdminUserGroup> AdminUserGroups { get; set; } = [];
public virtual ICollection<UserGroupRole> UserGroupRoles { get; set; } = [];
public virtual ICollection<UserGroupRestriction> UserGroupRestrictions { get; set; } = [];
}How It Works at Login
At authentication, the AdminRepository implements a single-query projection pattern (GetWithRolesAsync) to fetch direct and group-inherited roles and restrictions in one database round-trip. Group-inherited roles are projected into synthetic AdminRole instances (with Id = Guid.Empty) and appended to the admin's Roles collection. AdminSecurityService then performs an additive union merge of role-level and group-level field restrictions (where 'deny wins' for FLS).
Member Management
Adding members is idempotent — posting an admin ID that is already a member silently succeeds. Removing a member removes the junction record; the admin retains all directly-assigned roles. The member list is queryable with admin metadata (name, email, status).
Role Assignment
Group roles use a nuke-and-pave pattern (PUT replaces all). This ensures the database always matches the UI state. Each role must belong to the same tenant as the group. Roles assigned via groups appear alongside directly-assigned roles in the admin's effective permission set.
Field Restrictions
Group restrictions follow the same model as role-level RestrictedFields. Each restriction targets a specific permission code and lists the fields to hide. At login, the system takes the UNION of all restricted fields across direct roles and all group memberships — if any source restricts 'salary', it's restricted regardless of other assignments.
// Group-level field restriction stored in UserGroupRestrictions table
{
"permissionCode": "admins.view",
"restrictedFieldsJson": "[\"salary\", \"bankAccount\", \"ssn\"]"
}
// Additive Field-Level Security Merging Example:
// 1. Direct role restrictions: ["salary"]
// 2. UserGroup A restrictions: ["salary", "bankAccount", "ssn"]
// 3. UserGroup B restrictions: ["nationalId"]
// 4. Merged result in JWT: ["salary", "bankAccount", "ssn", "nationalId"] (Union - Deny Wins)Cascade Operations
User Groups support bulk operations (activate, deactivate, delete, and their filter-based -all variants). If cascadeAdmins is enabled, deactivation or soft-delete cascades to group members. Cascade operations automatically skip Protected Admins (like the tenant creator). If cascadeAdmins is false, any orphaned admin who loses all role assignments is automatically transferred to the SYSTEM_DEFAULT fallback role.
API Endpoints (18)
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/UserGroups | Paginated list (filterable by TenantId, search, status) | 🔒 Required |
| GET | /api/v1/UserGroups/myTenantGroups | Paginated groups for current user's Tenant scope | 🔒 Required |
| GET | /api/v1/UserGroups/{id} | Detail response (contains active members, roles, and restrictions) | 🔒 Required |
| GET | /api/v1/UserGroups/byTenantId/{tenantId} | All groups belonging to a specific TenantId (SuperAdmin only) | 🔒 Required |
| POST | /api/v1/UserGroups | Create User Group for an explicit Tenant | 🔒 Required |
| POST | /api/v1/UserGroups/createForMyTenant | Create User Group bound to current tenant scope | 🔒 Required |
| PUT | /api/v1/UserGroups/{id} | Update metadata and perform nuke-and-pave of RoleIds | 🔒 Required |
| DELETE | /api/v1/UserGroups/{id} | Soft-delete User Group (supports cascadeAdmins query param) | 🔒 Required |
| POST | /api/v1/UserGroups/{groupId}/members | Add members (admins) to a group (idempotent) | 🔒 Required |
| DELETE | /api/v1/UserGroups/{groupId}/members/{adminId} | Remove member admin from a group | 🔒 Required |
| PUT | /api/v1/UserGroups/{groupId}/roles | Nuke-and-pave sync of group roles | 🔒 Required |
| PUT | /api/v1/UserGroups/{groupId}/restrictions | Nuke-and-pave sync of field restrictions | 🔒 Required |
| POST | /api/v1/UserGroups/bulk/activate | Bulk activate groups (supports cascadeAdmins body property) | 🔒 Required |
| POST | /api/v1/UserGroups/bulk/deactivate | Bulk deactivate groups (supports cascadeAdmins body property) | 🔒 Required |
| POST | /api/v1/UserGroups/bulk/delete | Bulk soft-delete groups (supports cascadeAdmins body property) | 🔒 Required |
| POST | /api/v1/UserGroups/bulk/activate-all | Bulk activate all matching filter (supports cascadeAdmins) | 🔒 Required |
| POST | /api/v1/UserGroups/bulk/deactivate-all | Bulk deactivate all matching filter (supports cascadeAdmins) | 🔒 Required |
| POST | /api/v1/UserGroups/bulk/delete-all | Bulk soft-delete all matching filter (supports cascadeAdmins) | 🔒 Required |
Frontend Module
The frontend utilizes a clean MVVM pattern. UserGroupService communicates with the backend, UserGroupRepository validates contracts with Zod schema verification (UserGroupModelSchema), and UserGroupMapper maps response DTOs. The useUserGroupsViewModel coordinates CRUD states, including custom deleteDialog and statusDialog state handlers for cascading actions.
| Component/File | MVVM Pattern Role | Description / Responsibilities |
|---|---|---|
| UserGroupService.ts | API Data Service | Communicates with backend PascalCase routes (/api/v1/UserGroups) and handles payload mapping. |
| UserGroupRepository.ts | Data Layer Repository | Mediates between UI and service. Leverages Zod schema validation to verify contract compliance. |
| UserGroupMapper.ts | Data Mapper | Converts response DTOs to UI domain entities, null-coalescing missing properties. |
| useUserGroupsViewModel.ts | Presentation ViewModel | Consumes repository via DI, drives table state, filters, and manages cascade delete/status modal dialogs. |
| UserGroupsView.tsx | Presentation View | Renders table columns, localized names, member badges, and links to bulk action execution. |
| UserGroupDetailView.tsx | Presentation View | Tabbed workspace details (Members, Roles, Restrictions) supporting nuke-and-pave updates. |