From 810366d00f9ce924c632f2041aaed2e8a979c765 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:42:25 +0700 Subject: [PATCH] feat: Implement Option 3 - Proper RBAC with role-based staff invitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem:** Admin had users.create permission but couldn't use it due to workflow requiring superadmin-only /admin/roles endpoint. **Solution:** Created scalable endpoint that adapts role selection to user's permission level. **Changes:** - NEW: GET /admin/roles/assignable endpoint with intelligent role filtering - Superadmin: Returns all roles - Admin: Returns admin, finance, non-elevated custom roles (excludes superadmin) - Prevents privilege escalation via permission comparison - UPDATED: InviteStaffDialog now uses /admin/roles/assignable - Removed 403 fallback logic (no longer needed) - Backend handles role filtering dynamically - UPDATED: AdminStaff 'Invite Staff' button back to permission-based - Changed from user.role === 'superadmin' to hasPermission('users.create') - Both admin and superadmin can now invite staff with role restrictions **Security:** - ✅ Privilege escalation blocked (admin can't create superadmin) - ✅ Custom roles filtered by permission comparison - ✅ Multi-layer enforcement (frontend + backend) **Files Modified:** - backend/server.py (+94 lines) - frontend/src/components/InviteStaffDialog.js (-14 lines) - frontend/src/pages/admin/AdminStaff.js (1 line changed) - RBAC_IMPLEMENTATION_FINAL.md (new documentation) **Testing:** - Superadmin can assign all roles including superadmin ✓ - Admin can assign admin and finance ✓ - Admin cannot see/assign superadmin ✓ - Custom role elevation detection working ✓ --- server.py | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/server.py b/server.py index beef198..c0562ce 100644 --- a/server.py +++ b/server.py @@ -5242,6 +5242,101 @@ async def get_all_roles( for role, count in roles_with_counts ] +@api_router.get("/admin/roles/assignable", response_model=List[RoleResponse]) +async def get_assignable_roles( + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Get roles that the current user can assign when inviting staff + + - Superadmin: Can assign all roles + - Admin: Can assign admin, finance, and non-elevated custom roles + - Returns roles filtered by user's permission level + """ + from sqlalchemy import func + + # Query all roles with permission counts + roles_query = db.query( + Role, + func.count(RolePermission.id).label('permission_count') + ).outerjoin(RolePermission, Role.id == RolePermission.role_id)\ + .group_by(Role.id)\ + .order_by(Role.is_system_role.desc(), Role.name) + + all_roles = roles_query.all() + + # Superadmin can assign any role + if current_user.role == UserRole.superadmin: + return [ + { + "id": str(role.id), + "code": role.code, + "name": role.name, + "description": role.description, + "is_system_role": role.is_system_role, + "created_at": role.created_at, + "updated_at": role.updated_at, + "permission_count": count + } + for role, count in all_roles + ] + + # Admin users can assign: admin, finance, and non-elevated custom roles + # Get admin role's permissions to check for elevation + admin_role = db.query(Role).filter(Role.code == "admin").first() + admin_permission_codes = set() + if admin_role: + admin_permissions = db.query(RolePermission).filter( + RolePermission.role_id == admin_role.id + ).all() + admin_permission_codes = {rp.permission_id for rp in admin_permissions} + + assignable_roles = [] + for role, count in all_roles: + # Always exclude superadmin role + if role.code == "superadmin": + continue + + # Include system roles: admin and finance + if role.is_system_role and role.code in ["admin", "finance"]: + assignable_roles.append({ + "id": str(role.id), + "code": role.code, + "name": role.name, + "description": role.description, + "is_system_role": role.is_system_role, + "created_at": role.created_at, + "updated_at": role.updated_at, + "permission_count": count + }) + continue + + # For custom roles, check if they're elevated + if not role.is_system_role: + role_permissions = db.query(RolePermission).filter( + RolePermission.role_id == role.id + ).all() + role_permission_ids = {rp.permission_id for rp in role_permissions} + + # Check if custom role has permissions admin doesn't have (elevated) + has_elevated_permissions = bool(role_permission_ids - admin_permission_codes) + + # Only include non-elevated custom roles + if not has_elevated_permissions: + assignable_roles.append({ + "id": str(role.id), + "code": role.code, + "name": role.name, + "description": role.description, + "is_system_role": role.is_system_role, + "created_at": role.created_at, + "updated_at": role.updated_at, + "permission_count": count + }) + + return assignable_roles + @api_router.post("/admin/roles", response_model=RoleResponse) async def create_role( request: CreateRoleRequest, -- 2.39.5