forked from andika/membership-be
feat: Implement Option 3 - Proper RBAC with role-based staff invitations
**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 ✓
This commit is contained in:
95
server.py
95
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,
|
||||
|
||||
Reference in New Issue
Block a user