Repository: freedomofpress/securedrop

Issue:
Add `/users` endpoint to API
In order to reliably update local user databases (see, for example, https://github.com/freedomofpress/securedrop-client/issues/1143), it would be useful for the API to expose a `/users` endpoint.

Currently, non-admin users do not have the ability to see a list of all users via the *Journalist Interface* , so we may only want to return the UUID for each user (and potentially a property like `is_deleted` or `is_locked` if/when such functionality is implemented, cf. #5467).


Discussion:
Just want to make a note of what bug this would currently solve. Right now, we learn whether or not a user has been deleted through the `replies` endpoint. If a user does not have any successful replies on the server and they are deleted, we will not learn whether or not that user has been deleted. From the client, even though the user would not be able to log in, it would look like that user has an active account. Also if that user has any draft replies that never made it to the server, those replies will continue to exist locally and will continue to be attributed to the user, which others will be able to see in the GUI.
A few updates on this issue:
1. We've provisionally agreed that this would be a useful endpoint to add, either in 1.6.0, or after.
2. We would likely want to return user name, first name, and last name, in addition to the UUID, but _not_ the `is_admin` flag.
3. After some back and forth, we agreed it should be called `/users`, to be consistent with the existing `/user` endpoint, and because it's more inclusive of users in different roles in an org.
4. In terms of additional disclosure of user information to non-admins, while non-admins can't currently enumerate all users, they can enumerate _many_ users, since the `/replies` endpoint discloses the same user data which would be provided by this endpoint, for users who have sent replies.
5. Once this lands, in a future release, we could consider removing the full user data from the `/replies` endpoint (returning only the UUID of reply authors). This should only be done once the production release of the SecureDrop Client no longer relies on it.

I've raised my hand for putting together a PR for this issue, and then we can reason a bit more about 1) any additional impact in the context of our threat model, 2) whether or not this should land in 1.6.0.

Resolving patch:
diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py
--- a/securedrop/journalist_app/api.py
+++ b/securedrop/journalist_app/api.py
@@ -66,6 +66,7 @@ def make_blueprint(config: SDConfig) -> Blueprint:
     def get_endpoints() -> Tuple[flask.Response, int]:
         endpoints = {'sources_url': '/api/v1/sources',
                      'current_user_url': '/api/v1/user',
+                     'all_users_url': '/api/v1/users',
                      'submissions_url': '/api/v1/submissions',
                      'replies_url': '/api/v1/replies',
                      'auth_token_url': '/api/v1/token'}
@@ -323,6 +324,13 @@ def get_current_user() -> Tuple[flask.Response, int]:
         user = _authenticate_user_from_auth_header(request)
         return jsonify(user.to_json()), 200
 
+    @api.route('/users', methods=['GET'])
+    @token_required
+    def get_all_users() -> Tuple[flask.Response, int]:
+        users = Journalist.query.all()
+        return jsonify(
+            {'users': [user.to_json(all_info=False) for user in users]}), 200
+
     @api.route('/logout', methods=['POST'])
     @token_required
     def logout() -> Tuple[flask.Response, int]:
diff --git a/securedrop/models.py b/securedrop/models.py
--- a/securedrop/models.py
+++ b/securedrop/models.py
@@ -710,15 +710,25 @@ def validate_api_token_and_get_user(token: str) -> 'Optional[Journalist]':
 
         return Journalist.query.get(data['id'])
 
-    def to_json(self) -> 'Dict[str, Union[str, bool, str]]':
+    def to_json(self, all_info: bool = True) -> 'Dict[str, Union[str, bool, str]]':
+        """Returns a JSON representation of the journalist user. If all_info is
+           False, potentially sensitive or extraneous fields are excluded. Note
+           that both representations do NOT include credentials."""
+
         json_user = {
             'username': self.username,
-            'last_login': self.last_access.isoformat() + 'Z',
-            'is_admin': self.is_admin,
             'uuid': self.uuid,
             'first_name': self.first_name,
             'last_name': self.last_name
         }
+
+        if all_info is True:
+            json_user['is_admin'] = self.is_admin
+            try:
+                json_user['last_login'] = self.last_access.isoformat() + 'Z'
+            except AttributeError:
+                json_user['last_login'] = None
+
         return json_user
 
 


Test patch:
diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py
--- a/securedrop/tests/test_journalist_api.py
+++ b/securedrop/tests/test_journalist_api.py
@@ -22,9 +22,9 @@ def test_unauthenticated_user_gets_all_endpoints(journalist_app):
     with journalist_app.test_client() as app:
         response = app.get(url_for('api.get_endpoints'))
 
-        expected_endpoints = ['current_user_url', 'submissions_url',
-                              'sources_url', 'auth_token_url',
-                              'replies_url']
+        expected_endpoints = ['current_user_url', 'all_users_url',
+                              'submissions_url', 'sources_url',
+                              'auth_token_url', 'replies_url']
         expected_endpoints.sort()
         sorted_observed_endpoints = list(response.json.keys())
         sorted_observed_endpoints.sort()
@@ -153,7 +153,8 @@ def test_user_without_token_cannot_get_protected_endpoints(journalist_app,
             url_for('api.single_reply', source_uuid=uuid,
                     reply_uuid=test_files['replies'][0].uuid),
             url_for('api.all_source_replies', source_uuid=uuid),
-            url_for('api.get_current_user')
+            url_for('api.get_current_user'),
+            url_for('api.get_all_users'),
             ]
 
     with journalist_app.test_client() as app:
@@ -639,6 +640,25 @@ def test_authorized_user_can_get_current_user_endpoint(journalist_app,
         assert response.json['last_name'] == test_journo['journalist'].last_name
 
 
+def test_authorized_user_can_get_all_users(journalist_app, test_journo, test_admin,
+                                           journalist_api_token):
+    with journalist_app.test_client() as app:
+        response = app.get(url_for('api.get_all_users'),
+                           headers=get_api_headers(journalist_api_token))
+
+        assert response.status_code == 200
+
+        # Ensure that all the users in the database are returned
+        observed_users = [user['uuid'] for user in response.json['users']]
+        expected_users = [user.uuid for user in Journalist.query.all()]
+        assert observed_users == expected_users
+
+        # Ensure that no fields other than the expected ones are returned
+        expected_fields = ['first_name', 'last_name', 'username', 'uuid']
+        for user in response.json['users']:
+            assert sorted(user.keys()) == expected_fields
+
+
 def test_request_with_missing_auth_header_triggers_403(journalist_app):
     with journalist_app.test_client() as app:
         response = app.get(url_for('api.get_current_user'),