Compare commits

..

2 Commits

Author SHA1 Message Date
kayela
38e5f5377a Merge branch 'dev' into docker 2026-01-06 12:31:29 -06:00
Koncept Kit
810366d00f 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 ✓
2026-01-06 14:42:25 +07:00

View File

@@ -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,