Repository: apache/airflow

Issue:
POST /api/v1/users fails with exception
### Apache Airflow version

main (development)

### Operating System

From Astronomer’s QA team

### Versions of Apache Airflow Providers

_No response_

### Deployment

Astronomer

### Deployment details

_No response_

### What happened

When adding a new user, The following exception is emitted:

```
Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 2447, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1952, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1821, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python3.9/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1950, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1936, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/usr/local/lib/python3.9/site-packages/airflow/_vendor/connexion/decorators/decorator.py", line 48, in wrapper
    response = function(request)
  File "/usr/local/lib/python3.9/site-packages/airflow/_vendor/connexion/decorators/uri_parsing.py", line 144, in wrapper
    response = function(request)
  File "/usr/local/lib/python3.9/site-packages/airflow/_vendor/connexion/decorators/validation.py", line 184, in wrapper
    response = function(request)
  File "/usr/local/lib/python3.9/site-packages/airflow/_vendor/connexion/decorators/response.py", line 103, in wrapper
    response = function(request)
  File "/usr/local/lib/python3.9/site-packages/airflow/_vendor/connexion/decorators/parameter.py", line 121, in wrapper
    return function(**kwargs)
  File "/usr/local/lib/python3.9/site-packages/airflow/api_connexion/security.py", line 47, in decorated
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/airflow/api_connexion/endpoints/user_endpoint.py", line 105, in post_user
    user.roles.extend(roles_to_add)
AttributeError: 'bool' object has no attribute 'roles'
```

The immediate cause to this exception is F.A.B. returns `False` when it fails to add a new user. The problem, however, is _why_ excactly it failed. This is the payload used:

```json
{
    "username": "username6",
    "password": "password1",
    "email": "username5@example.com",
    "first_name": "user2",
    "last_name": "test1",
    "roles":[{"name":"Admin"},{"name":"Viewer"}]
}
```

This went through validation, therefore we know

1. The POST-ing user has permission to create a new user.
2. The format is correct (including the nested roles).
3. There is not already an existing `username6` in the database.
4. All listed roles exist.

(All these are already covered by unit tests.)

Further complicating the issue is F.A.B.’s security manager swallows an exception when this happens, and only logs the exception to the server. And we’re having trouble locating that line of log. It’s quite difficult to diagnose further, so I’m posting this hoping someone has better luck reproducing this.

I will submit a fix to correct the immediate issue, making the API emit 500 with something like “Failed to create user for unknown reason” to make the failure _slightly_ less confusing.

### What you expected to happen

_No response_

### How to reproduce

_No response_

### Anything else

_No response_

### Are you willing to submit PR?

- [X] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:
On further investigating, the problem seems to be the email field, F.A.B. seems to have a UNIQUE key on it. I’ll do a PR.

Resolving patch:
diff --git a/airflow/api_connexion/endpoints/user_endpoint.py b/airflow/api_connexion/endpoints/user_endpoint.py
--- a/airflow/api_connexion/endpoints/user_endpoint.py
+++ b/airflow/api_connexion/endpoints/user_endpoint.py
@@ -21,7 +21,7 @@
 from werkzeug.security import generate_password_hash
 
 from airflow.api_connexion import security
-from airflow.api_connexion.exceptions import AlreadyExists, BadRequest, NotFound
+from airflow.api_connexion.exceptions import AlreadyExists, BadRequest, NotFound, Unknown
 from airflow.api_connexion.parameters import apply_sorting, check_limit, format_parameters
 from airflow.api_connexion.schemas.user_schema import (
     UserCollection,
@@ -76,10 +76,14 @@ def post_user():
         raise BadRequest(detail=str(e.messages))
 
     security_manager = current_app.appbuilder.sm
+    username = data["username"]
+    email = data["email"]
 
-    user = security_manager.find_user(username=data["username"])
-    if user is not None:
-        detail = f"Username `{user.username}` already exists. Use PATCH to update."
+    if security_manager.find_user(username=username):
+        detail = f"Username `{username}` already exists. Use PATCH to update."
+        raise AlreadyExists(detail=detail)
+    if security_manager.find_user(email=email):
+        detail = f"The email `{email}` is already taken."
         raise AlreadyExists(detail=detail)
 
     roles_to_add = []
@@ -101,6 +105,10 @@ def post_user():
         default_role = security_manager.find_role(security_manager.auth_user_registration_role)
 
     user = security_manager.add_user(role=default_role, **data)
+    if not user:
+        detail = f"Failed to add user `{username}`."
+        return Unknown(detail=detail)
+
     if roles_to_add:
         user.roles.extend(roles_to_add)
         security_manager.update_user(user)


Test patch:
diff --git a/tests/api_connexion/endpoints/test_user_endpoint.py b/tests/api_connexion/endpoints/test_user_endpoint.py
--- a/tests/api_connexion/endpoints/test_user_endpoint.py
+++ b/tests/api_connexion/endpoints/test_user_endpoint.py
@@ -240,10 +240,12 @@ def test_should_return_conf_max_if_req_max_above_conf(self):
 
 EXAMPLE_USER_NAME = "example_user"
 
+EXAMPLE_USER_EMAIL = "example_user@example.com"
 
-def _delete_example_user():
+
+def _delete_user(**filters):
     with create_session() as session:
-        user = session.query(User).filter(User.username == EXAMPLE_USER_NAME).first()
+        user = session.query(User).filter_by(**filters).first()
         if user is None:
             return
         user.roles = []
@@ -252,17 +254,60 @@ def _delete_example_user():
 
 @pytest.fixture()
 def autoclean_username():
-    _delete_example_user()
+    _delete_user(username=EXAMPLE_USER_NAME)
     yield EXAMPLE_USER_NAME
-    _delete_example_user()
+    _delete_user(username=EXAMPLE_USER_NAME)
+
+
+@pytest.fixture()
+def autoclean_email():
+    _delete_user(email=EXAMPLE_USER_EMAIL)
+    yield EXAMPLE_USER_EMAIL
+    _delete_user(email=EXAMPLE_USER_EMAIL)
+
+
+@pytest.fixture()
+def user_with_same_username(configured_app, autoclean_username):
+    user = create_user(
+        configured_app,
+        username=autoclean_username,
+        email="another_user@example.com",
+        role_name="TestNoPermissions",
+    )
+    assert user, f"failed to create user '{autoclean_username} <another_user@example.com>'"
+    return user
+
+
+@pytest.fixture()
+def user_with_same_email(configured_app, autoclean_email):
+    user = create_user(
+        configured_app,
+        username="another_user",
+        email=autoclean_email,
+        role_name="TestNoPermissions",
+    )
+    assert user, f"failed to create user 'another_user <{autoclean_email}>'"
+    return user
+
+
+@pytest.fixture()
+def user_different(configured_app):
+    username = "another_user"
+    email = "another_user@example.com"
+
+    _delete_user(username=username, email=email)
+    user = create_user(configured_app, username=username, email=email, role_name="TestNoPermissions")
+    assert user, "failed to create user 'another_user <another_user@example.com>'"
+    yield user
+    _delete_user(username=username, email=email)
 
 
 @pytest.fixture()
-def autoclean_user_payload(autoclean_username):
+def autoclean_user_payload(autoclean_username, autoclean_email):
     return {
         "username": autoclean_username,
         "password": "resutsop",
-        "email": "test@example.com",
+        "email": autoclean_email,
         "first_name": "Example",
         "last_name": "User",
     }
@@ -304,6 +349,15 @@ def test_with_custom_roles(self, autoclean_username, autoclean_user_payload):
         assert user is not None
         assert {r.name for r in user.roles} == {"User", "Viewer"}
 
+    @pytest.mark.usefixtures("user_different")
+    def test_with_existing_different_user(self, autoclean_user_payload):
+        response = self.client.post(
+            "/api/v1/users",
+            json={"roles": [{"name": "User"}, {"name": "Viewer"}], **autoclean_user_payload},
+            environ_overrides={"REMOTE_USER": "test"},
+        )
+        assert response.status_code == 200, response.json
+
     def test_unauthenticated(self, autoclean_user_payload):
         response = self.client.post(
             "/api/v1/users",
@@ -319,8 +373,22 @@ def test_forbidden(self, autoclean_user_payload):
         )
         assert response.status_code == 403, response.json
 
-    def test_already_exists(self, autoclean_username, autoclean_user_payload):
-        create_user(self.app, username=autoclean_username, role_name="TestNoPermissions")
+    @pytest.mark.parametrize(
+        "existing_user_fixture_name, error_detail_template",
+        [
+            ("user_with_same_username", "Username `{username}` already exists. Use PATCH to update."),
+            ("user_with_same_email", "The email `{email}` is already taken."),
+        ],
+        ids=["username", "email"],
+    )
+    def test_already_exists(
+        self,
+        request,
+        autoclean_user_payload,
+        existing_user_fixture_name,
+        error_detail_template,
+    ):
+        existing = request.getfixturevalue(existing_user_fixture_name)
 
         response = self.client.post(
             "/api/v1/users",
@@ -329,6 +397,9 @@ def test_already_exists(self, autoclean_username, autoclean_user_payload):
         )
         assert response.status_code == 409, response.json
 
+        error_detail = error_detail_template.format(username=existing.username, email=existing.email)
+        assert response.json["detail"] == error_detail
+
     @pytest.mark.parametrize(
         "payload_converter, error_message",
         [
diff --git a/tests/test_utils/api_connexion_utils.py b/tests/test_utils/api_connexion_utils.py
--- a/tests/test_utils/api_connexion_utils.py
+++ b/tests/test_utils/api_connexion_utils.py
@@ -18,7 +18,7 @@
 from airflow.www.security import EXISTING_ROLES
 
 
-def create_user(app, username, role_name, permissions=None):
+def create_user(app, username, role_name, email=None, permissions=None):
     appbuilder = app.appbuilder
 
     # Removes user and role so each test has isolated test data.
@@ -26,11 +26,14 @@ def create_user(app, username, role_name, permissions=None):
     delete_role(app, role_name)
     role = create_role(app, role_name, permissions)
 
+    if email is None:
+        email = f"{username}@example.org"
+
     return appbuilder.sm.add_user(
         username=username,
         first_name=username,
         last_name=username,
-        email=f"{username}@fab.org",
+        email=email,
         role=role,
         password=username,
     )