Repository: CTFd/CTFd

Issue:
Changing an Admin back to a unprivileged User raise two exceptions and prevent the page to reload
**Environment**:

- CTFd Version/Commit: 3.2.1
- Operating System: Ubuntu
- Web Browser and Version: Firefox 85.0

**What happened?**

I was playing with the users settings when I found this issue: 

- If I change an User into an Admin from the "Edit User" page there is no issue, everything is fine and the page reloads
- But if I change an Admin back into a unprivileged User, the page won't reload and two exceptions can be found in the console (see stack traces below) 

From what I understand the issue is from the users `patch` API method which is using SQLAlchemy. So maybe the issue is linked to the Admins class from the model and the use of the polymorphic_identity but I'm not sure.
https://github.com/CTFd/CTFd/blob/9374c2a0a85308310d25538cdcea43c7c2d0d99d/CTFd/api/v1/users.py#L217-L245

However, the type of the User is correctly changed in the database.

**What did you expect to happen?**

The page should have reload from itself after I changed an Admin into a regular User

**How to reproduce your issue**

1. Set up a new CTFd with the User mode
2. Create a new user
3. Change this user into an Admin
4. Change back the user from an Admin to an unprivileged User

**Any associated stack traces or error logs**

```
/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/sqlalchemy/orm/mapper.py:1693: SAWarning: Flushing object <Admins at 0x7fbab8f734d0> with incompatible polymorphic identity 'user'; the object may not refresh and/or load correctly (this warning may be suppressed after 10 occurrences)
  (state_str(state), dict_[polymorphic_key]),
127.0.0.1 - - [05/Feb/2021 15:23:50] "PATCH /api/v1/users/2 HTTP/1.1" 500 -
INFO  [werkzeug] 127.0.0.1 - - [05/Feb/2021 15:23:50] "PATCH /api/v1/users/2 HTTP/1.1" 500 -
Traceback (most recent call last):
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/marshmallow/utils.py", line 349, in _get_value_for_key
    return obj[key]
TypeError: 'Admins' object is not subscriptable

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask/app.py", line 2464, in __call__
    return self.wsgi_app(environ, start_response)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask/app.py", line 2450, in wsgi_app
    response = self.handle_exception(e)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask_restx/api.py", line 638, in error_router
    return original_handler(f)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask/app.py", line 1867, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask_restx/api.py", line 636, in error_router
    return self.handle_error(e)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask/app.py", line 2447, in wsgi_app
    response = self.full_dispatch_request()
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask/app.py", line 1952, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask_restx/api.py", line 638, in error_router
    return original_handler(f)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask/app.py", line 1821, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask_restx/api.py", line 636, in error_router
    return self.handle_error(e)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask/app.py", line 1950, in full_dispatch_request
    rv = self.dispatch_request()
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask/app.py", line 1936, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask_restx/api.py", line 375, in wrapper
    resp = resource(*args, **kwargs)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask/views.py", line 89, in view
    return self.dispatch_request(*args, **kwargs)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/flask_restx/resource.py", line 44, in dispatch_request
    resp = meth(*args, **kwargs)
  File "/home/user/Downloads/CTFd-3.2.1/CTFd/utils/decorators/__init__.py", line 133, in admins_only_wrapper
    return f(*args, **kwargs)
  File "/home/user/Downloads/CTFd-3.2.1/CTFd/api/v1/users.py", line 238, in patch
    response = schema.dump(response.data)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/marshmallow/schema.py", line 521, in dump
    **kwargs
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/marshmallow/marshalling.py", line 140, in serialize
    index=(index if index_errors else None)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/marshmallow/marshalling.py", line 63, in call_and_store
    value = getter_func(data)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/marshmallow/marshalling.py", line 134, in <lambda>
    getter = lambda d: field_obj.serialize(attr_name, d, accessor=accessor)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/marshmallow/fields.py", line 243, in serialize
    value = self.get_value(attr, obj, accessor=accessor)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/marshmallow/fields.py", line 186, in get_value
    return accessor_func(check_key, obj, default)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/marshmallow/schema.py", line 415, in get_attribute
    return utils.get_value(attr, obj, default)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/marshmallow/utils.py", line 336, in get_value
    return _get_value_for_keys(key.split('.'), obj, default)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/marshmallow/utils.py", line 341, in _get_value_for_keys
    return _get_value_for_key(keys[0], obj, default)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/marshmallow/utils.py", line 352, in _get_value_for_key
    attr = getattr(obj, key)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/sqlalchemy/orm/attributes.py", line 287, in __get__
    return self.impl.get(instance_state(instance), dict_)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/sqlalchemy/orm/attributes.py", line 718, in get
    value = state._load_expired(state, passive)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/sqlalchemy/orm/state.py", line 652, in _load_expired
    self.manager.deferred_scalar_loader(self, toload)
  File "/home/user/Downloads/CTFd-3.2.1/env/lib/python3.7/site-packages/sqlalchemy/orm/loading.py", line 1018, in load_scalar_attributes
    raise orm_exc.ObjectDeletedError(state)
sqlalchemy.orm.exc.ObjectDeletedError: Instance '<Admins at 0x7fbab8f734d0>' has been deleted, or its row is otherwise not present.
```


Discussion:

Resolving patch:
diff --git a/CTFd/api/v1/users.py b/CTFd/api/v1/users.py
--- a/CTFd/api/v1/users.py
+++ b/CTFd/api/v1/users.py
@@ -233,16 +233,18 @@ def patch(self, user_id):
         if response.errors:
             return {"success": False, "errors": response.errors}, 400
 
-        db.session.commit()
-
+        # This generates the response first before actually changing the type
+        # This avoids an error during User type changes where we change
+        # the polymorphic identity resulting in an ObjectDeletedError
+        # https://github.com/CTFd/CTFd/issues/1794
         response = schema.dump(response.data)
-
+        db.session.commit()
         db.session.close()
 
         clear_user_session(user_id=user_id)
         clear_standings()
 
-        return {"success": True, "data": response}
+        return {"success": True, "data": response.data}
 
     @admins_only
     @users_namespace.doc(


Test patch:
diff --git a/tests/api/v1/test_users.py b/tests/api/v1/test_users.py
--- a/tests/api/v1/test_users.py
+++ b/tests/api/v1/test_users.py
@@ -308,7 +308,7 @@ def test_api_user_patch_admin():
                 },
             )
             assert r.status_code == 200
-            user_data = r.get_json()["data"][0]
+            user_data = r.get_json()["data"]
             assert user_data["country"] == "US"
             assert user_data["verified"] is True
     destroy_ctfd(app)
diff --git a/tests/api/v1/users/test_users.py b/tests/api/v1/users/test_users.py
--- a/tests/api/v1/users/test_users.py
+++ b/tests/api/v1/users/test_users.py
@@ -1,7 +1,7 @@
 #!/usr/bin/env python
 # -*- coding: utf-8 -*-
 
-from tests.helpers import create_ctfd, destroy_ctfd, login_as_user
+from tests.helpers import create_ctfd, destroy_ctfd, login_as_user, register_user
 
 
 def test_api_self_ban():
@@ -15,3 +15,23 @@ def test_api_self_ban():
             assert resp["success"] == False
             assert resp["errors"] == {"id": "You cannot ban yourself"}
     destroy_ctfd(app)
+
+
+def test_api_modify_user_type():
+    """Can a user patch /api/v1/users/<user_id> to promote a user to admin and demote them to user"""
+    app = create_ctfd()
+    with app.app_context():
+        register_user(app)
+        with login_as_user(app, "admin") as client:
+            r = client.patch("/api/v1/users/2", json={"type": "admin"})
+            assert r.status_code == 200
+            user_data = r.get_json()["data"]
+            assert user_data["name"] == "user"
+            assert user_data["type"] == "admin"
+
+            r = client.patch("/api/v1/users/2", json={"type": "user"})
+            assert r.status_code == 200
+            user_data = r.get_json()["data"]
+            assert user_data["name"] == "user"
+            assert user_data["type"] == "user"
+    destroy_ctfd(app)