From 654d206033c3e372fd65ce34fbe7a1d3878e1072 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Mon, 25 Nov 2019 09:45:53 +0100 Subject: [PATCH] Server CLI: user management --- api/funkwhale_api/cli/__init__.py | 0 api/funkwhale_api/cli/base.py | 65 +++++++ api/funkwhale_api/cli/main.py | 19 ++ api/funkwhale_api/cli/users.py | 234 +++++++++++++++++++++++++ api/funkwhale_api/cli/utils.py | 3 + api/manage.py | 9 +- api/requirements/base.txt | 2 + api/tests/cli/__init__.py | 0 api/tests/cli/test_main.py | 118 +++++++++++++ api/tests/cli/test_users.py | 147 ++++++++++++++++ changes/changelog.d/server-cli.feature | 1 + changes/notes.rst | 15 ++ docs/admin/commands.rst | 88 ++++++++++ 13 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 api/funkwhale_api/cli/__init__.py create mode 100644 api/funkwhale_api/cli/base.py create mode 100644 api/funkwhale_api/cli/main.py create mode 100644 api/funkwhale_api/cli/users.py create mode 100644 api/funkwhale_api/cli/utils.py create mode 100644 api/tests/cli/__init__.py create mode 100644 api/tests/cli/test_main.py create mode 100644 api/tests/cli/test_users.py create mode 100644 changes/changelog.d/server-cli.feature diff --git a/api/funkwhale_api/cli/__init__.py b/api/funkwhale_api/cli/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/funkwhale_api/cli/base.py b/api/funkwhale_api/cli/base.py new file mode 100644 index 0000000000..439d91ff6f --- /dev/null +++ b/api/funkwhale_api/cli/base.py @@ -0,0 +1,65 @@ +import click +import functools + + +@click.group() +def cli(): + pass + + +def confirm_action(f, id_var, message_template="Do you want to proceed?"): + @functools.wraps(f) + def action(*args, **kwargs): + if id_var: + id_value = kwargs[id_var] + message = message_template.format(len(id_value)) + else: + message = message_template + if not kwargs.pop("no_input", False) and not click.confirm(message, abort=True): + return + + return f(*args, **kwargs) + + return action + + +def delete_command( + group, + id_var="id", + name="rm", + message_template="Do you want to delete {} objects? This action is irreversible.", +): + """ + Wrap a command to ensure it asks for confirmation before deletion, unless the --no-input + flag is provided + """ + + def decorator(f): + decorated = click.option("--no-input", is_flag=True)(f) + decorated = confirm_action( + decorated, id_var=id_var, message_template=message_template + ) + return group.command(name)(decorated) + + return decorator + + +def update_command( + group, + id_var="id", + name="set", + message_template="Do you want to update {} objects? This action may have irreversible consequnces.", +): + """ + Wrap a command to ensure it asks for confirmation before deletion, unless the --no-input + flag is provided + """ + + def decorator(f): + decorated = click.option("--no-input", is_flag=True)(f) + decorated = confirm_action( + decorated, id_var=id_var, message_template=message_template + ) + return group.command(name)(decorated) + + return decorator diff --git a/api/funkwhale_api/cli/main.py b/api/funkwhale_api/cli/main.py new file mode 100644 index 0000000000..a51cca1fd0 --- /dev/null +++ b/api/funkwhale_api/cli/main.py @@ -0,0 +1,19 @@ +import click +import sys + +from . import base +from . import users # noqa + +from rest_framework.exceptions import ValidationError + + +def invoke(): + try: + return base.cli() + except ValidationError as e: + click.secho("Invalid data:", fg="red") + for field, errors in e.detail.items(): + click.secho(" {}:".format(field), fg="red") + for error in errors: + click.secho(" - {}".format(error), fg="red") + sys.exit(1) diff --git a/api/funkwhale_api/cli/users.py b/api/funkwhale_api/cli/users.py new file mode 100644 index 0000000000..678b19f810 --- /dev/null +++ b/api/funkwhale_api/cli/users.py @@ -0,0 +1,234 @@ +import click + +from django.db import transaction + +from funkwhale_api.federation import models as federation_models +from funkwhale_api.users import models +from funkwhale_api.users import serializers +from funkwhale_api.users import tasks + +from . import base +from . import utils + + +class FakeRequest(object): + def __init__(self, session={}): + self.session = session + + +@transaction.atomic +def handler_create_user( + username, + password, + email, + is_superuser=False, + is_staff=False, + permissions=[], + upload_quota=None, +): + serializer = serializers.RS( + data={ + "username": username, + "email": email, + "password1": password, + "password2": password, + } + ) + utils.logger.debug("Validating user data…") + serializer.is_valid(raise_exception=True) + + # Override email validation, we assume accounts created from CLI have a valid email + request = FakeRequest(session={"account_verified_email": email}) + utils.logger.debug("Creating user…") + user = serializer.save(request=request) + utils.logger.debug("Setting permissions and other attributes…") + user.is_staff = is_staff + user.upload_quota = upload_quota + user.is_superuser = is_superuser + for permission in permissions: + if permission in models.PERMISSIONS: + utils.logger.debug("Setting %s permission to True", permission) + setattr(user, "permission_{}".format(permission), True) + else: + utils.logger.warn("Unknown permission %s", permission) + utils.logger.debug("Creating actor…") + user.actor = models.create_actor(user) + user.save() + return user + + +@transaction.atomic +def handler_delete_user(usernames, soft=True): + for username in usernames: + click.echo("Deleting {}…".format(username)) + actor = None + user = None + try: + user = models.User.objects.get(username=username) + except models.User.DoesNotExist: + try: + actor = federation_models.Actor.objects.local().get( + preferred_username=username + ) + except federation_models.Actor.DoesNotExist: + click.echo(" Not found, skipping") + continue + + actor = actor or user.actor + if user: + tasks.delete_account(user_id=user.pk) + if not soft: + click.echo(" Hard delete, removing actor") + actor.delete() + click.echo(" Done") + + +@transaction.atomic +def handler_update_user(usernames, kwargs): + users = models.User.objects.filter(username__in=usernames) + total = users.count() + if not total: + click.echo("No matching users") + return + + final_kwargs = {} + supported_fields = [ + "is_active", + "permission_moderation", + "permission_library", + "permission_settings", + "is_staff", + "is_superuser", + "upload_quota", + "password", + ] + for field in supported_fields: + try: + value = kwargs[field] + except KeyError: + continue + final_kwargs[field] = value + + click.echo( + "Updating {} on {} matching users…".format( + ", ".join(final_kwargs.keys()), total + ) + ) + if "password" in final_kwargs: + new_password = final_kwargs.pop("password") + for user in users: + user.set_password(new_password) + models.User.objects.bulk_update(users, ["password"]) + if final_kwargs: + users.update(**final_kwargs) + click.echo("Done!") + + +@base.cli.group() +def users(): + """Manage users""" + pass + + +@users.command() +@click.option("--username", "-u", prompt=True, required=True) +@click.option( + "-p", + "--password", + prompt="Password (leave empty to have a random one generated)", + hide_input=True, + envvar="FUNKWHALE_CLI_USER_PASSWORD", + default="", + help="If empty, a random password will be generated and displayed in console output", +) +@click.option( + "-e", + "--email", + prompt=True, + help="Email address to associate with the account", + required=True, +) +@click.option( + "-q", + "--upload-quota", + help="Upload quota (leave empty to use default pod quota)", + required=False, + default=None, + type=click.INT, +) +@click.option( + "--superuser/--no-superuser", default=False, +) +@click.option( + "--staff/--no-staff", default=False, +) +@click.option( + "--permission", multiple=True, +) +def create(username, password, email, superuser, staff, permission, upload_quota): + """Create a new user""" + generated_password = None + if password == "": + generated_password = models.User.objects.make_random_password() + user = handler_create_user( + username=username, + password=password or generated_password, + email=email, + is_superuser=superuser, + is_staff=staff, + permissions=permission, + upload_quota=upload_quota, + ) + click.echo("User {} created!".format(user.username)) + if generated_password: + click.echo(" Generated password: {}".format(generated_password)) + + +@base.delete_command(group=users, id_var="username") +@click.argument("username", nargs=-1) +@click.option( + "--hard/--no-hard", + default=False, + help="Purge all user-related info (allow recreating a user with the same username)", +) +def delete(username, hard): + """Delete given users""" + handler_delete_user(usernames=username, soft=not hard) + + +@base.update_command(group=users, id_var="username") +@click.argument("username", nargs=-1) +@click.option( + "--active/--inactive", + help="Mark as active or inactive (inactive users cannot login or use the service)", + default=None, +) +@click.option("--superuser/--no-superuser", default=None) +@click.option("--staff/--no-staff", default=None) +@click.option("--permission-library/--no-permission-library", default=None) +@click.option("--permission-moderation/--no-permission-moderation", default=None) +@click.option("--permission-settings/--no-permission-settings", default=None) +@click.option("--password", default=None, envvar="FUNKWHALE_CLI_USER_UPDATE_PASSWORD") +@click.option( + "-q", "--upload-quota", type=click.INT, +) +def update(username, **kwargs): + """Update attributes for given users""" + field_mapping = { + "active": "is_active", + "superuser": "is_superuser", + "staff": "is_staff", + } + final_kwargs = {} + for cli_field, value in kwargs.items(): + if value is None: + continue + model_field = ( + field_mapping[cli_field] if cli_field in field_mapping else cli_field + ) + final_kwargs[model_field] = value + + if not final_kwargs: + raise click.BadArgumentUsage("You need to update at least one attribute") + + handler_update_user(usernames=username, kwargs=final_kwargs) diff --git a/api/funkwhale_api/cli/utils.py b/api/funkwhale_api/cli/utils.py new file mode 100644 index 0000000000..08dd1a6fa6 --- /dev/null +++ b/api/funkwhale_api/cli/utils.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger("funkwhale_api.cli") diff --git a/api/manage.py b/api/manage.py index c8db95ede2..8331a33ece 100755 --- a/api/manage.py +++ b/api/manage.py @@ -17,4 +17,11 @@ if __name__ == "__main__": from django.core.management import execute_from_command_line - execute_from_command_line(sys.argv) + if len(sys.argv) > 1 and sys.argv[1] in ["fw", "funkwhale"]: + # trigger our own click-based cli + from funkwhale_api.cli import main + + sys.argv = sys.argv[1:] + main.invoke() + else: + execute_from_command_line(sys.argv) diff --git a/api/requirements/base.txt b/api/requirements/base.txt index cc24b10f62..b651d9c4d7 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -73,3 +73,5 @@ django-storages==1.7.1 boto3<3 unicode-slugify django-cacheops==4.2 + +click>=7,<8 diff --git a/api/tests/cli/__init__.py b/api/tests/cli/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/cli/test_main.py b/api/tests/cli/test_main.py new file mode 100644 index 0000000000..4cea9e414c --- /dev/null +++ b/api/tests/cli/test_main.py @@ -0,0 +1,118 @@ +import pytest + +from click.testing import CliRunner + +from funkwhale_api.cli import main +from funkwhale_api.cli import users + + +@pytest.mark.parametrize( + "cmd, args, handlers", + [ + ( + ("users", "create"), + ( + "--username", + "testuser", + "--password", + "testpassword", + "--email", + "test@hello.com", + "--upload-quota", + "35", + "--permission", + "library", + "--permission", + "moderation", + "--staff", + "--superuser", + ), + [ + ( + users, + "handler_create_user", + { + "username": "testuser", + "password": "testpassword", + "email": "test@hello.com", + "upload_quota": 35, + "permissions": ("library", "moderation"), + "is_staff": True, + "is_superuser": True, + }, + ) + ], + ), + ( + ("users", "rm"), + ("testuser1", "testuser2", "--no-input"), + [ + ( + users, + "handler_delete_user", + {"usernames": ("testuser1", "testuser2"), "soft": True}, + ) + ], + ), + ( + ("users", "rm"), + ("testuser1", "testuser2", "--no-input", "--hard",), + [ + ( + users, + "handler_delete_user", + {"usernames": ("testuser1", "testuser2"), "soft": False}, + ) + ], + ), + ( + ("users", "set"), + ( + "testuser1", + "testuser2", + "--no-input", + "--inactive", + "--upload-quota", + "35", + "--no-staff", + "--superuser", + "--permission-library", + "--no-permission-moderation", + "--no-permission-settings", + "--password", + "newpassword", + ), + [ + ( + users, + "handler_update_user", + { + "usernames": ("testuser1", "testuser2"), + "kwargs": { + "is_active": False, + "upload_quota": 35, + "is_staff": False, + "is_superuser": True, + "permission_library": True, + "permission_moderation": False, + "permission_settings": False, + "password": "newpassword", + }, + }, + ) + ], + ), + ], +) +def test_cli(cmd, args, handlers, mocker): + patched_handlers = {} + for module, path, _ in handlers: + patched_handlers[(module, path)] = mocker.spy(module, path) + + runner = CliRunner() + result = runner.invoke(main.base.cli, cmd + args) + + assert result.exit_code == 0, result.output + + for module, path, expected_call in handlers: + patched_handlers[(module, path)].assert_called_once_with(**expected_call) diff --git a/api/tests/cli/test_users.py b/api/tests/cli/test_users.py new file mode 100644 index 0000000000..5f0c63bdb0 --- /dev/null +++ b/api/tests/cli/test_users.py @@ -0,0 +1,147 @@ +import pytest + +from funkwhale_api.cli import users + + +def test_user_create_handler(factories, mocker, now): + kwargs = { + "username": "helloworld", + "password": "securepassword", + "is_superuser": False, + "is_staff": True, + "email": "hello@world.email", + "upload_quota": 35, + "permissions": ["moderation"], + } + set_password = mocker.spy(users.models.User, "set_password") + create_actor = mocker.spy(users.models, "create_actor") + user = users.handler_create_user(**kwargs) + + assert user.username == kwargs["username"] + assert user.is_superuser == kwargs["is_superuser"] + assert user.is_staff == kwargs["is_staff"] + assert user.date_joined >= now + assert user.upload_quota == kwargs["upload_quota"] + set_password.assert_called_once_with(user, kwargs["password"]) + create_actor.assert_called_once_with(user) + + expected_permissions = { + p: p in kwargs["permissions"] for p in users.models.PERMISSIONS + } + + assert user.all_permissions == expected_permissions + + +def test_user_delete_handler_soft(factories, mocker, now): + user1 = factories["federation.Actor"](local=True).user + actor1 = user1.actor + user2 = factories["federation.Actor"](local=True).user + actor2 = user2.actor + user3 = factories["federation.Actor"](local=True).user + delete_account = mocker.spy(users.tasks, "delete_account") + users.handler_delete_user([user1.username, user2.username, "unknown"]) + + assert delete_account.call_count == 2 + delete_account.assert_any_call(user_id=user1.pk) + with pytest.raises(user1.DoesNotExist): + user1.refresh_from_db() + + delete_account.assert_any_call(user_id=user2.pk) + with pytest.raises(user2.DoesNotExist): + user2.refresh_from_db() + + # soft delete, actor shouldn't be deleted + actor1.refresh_from_db() + actor2.refresh_from_db() + + # not deleted + user3.refresh_from_db() + + +def test_user_delete_handler_hard(factories, mocker, now): + user1 = factories["federation.Actor"](local=True).user + actor1 = user1.actor + user2 = factories["federation.Actor"](local=True).user + actor2 = user2.actor + user3 = factories["federation.Actor"](local=True).user + delete_account = mocker.spy(users.tasks, "delete_account") + users.handler_delete_user([user1.username, user2.username, "unknown"], soft=False) + + assert delete_account.call_count == 2 + delete_account.assert_any_call(user_id=user1.pk) + with pytest.raises(user1.DoesNotExist): + user1.refresh_from_db() + + delete_account.assert_any_call(user_id=user2.pk) + with pytest.raises(user2.DoesNotExist): + user2.refresh_from_db() + + # hard delete, actors are deleted as well + with pytest.raises(actor1.DoesNotExist): + actor1.refresh_from_db() + + with pytest.raises(actor2.DoesNotExist): + actor2.refresh_from_db() + + # not deleted + user3.refresh_from_db() + + +@pytest.mark.parametrize( + "params, expected", + [ + ({"is_active": False}, {"is_active": False}), + ( + {"is_staff": True, "is_superuser": True}, + {"is_staff": True, "is_superuser": True}, + ), + ({"upload_quota": 35}, {"upload_quota": 35}), + ( + { + "permission_library": True, + "permission_moderation": True, + "permission_settings": True, + }, + { + "all_permissions": { + "library": True, + "moderation": True, + "settings": True, + } + }, + ), + ], +) +def test_user_update_handler(params, expected, factories): + user1 = factories["federation.Actor"](local=True).user + user2 = factories["federation.Actor"](local=True).user + user3 = factories["federation.Actor"](local=True).user + + def get_field_values(user): + return {f: getattr(user, f) for f, v in expected.items()} + + unchanged = get_field_values(user3) + + users.handler_update_user([user1.username, user2.username, "unknown"], params) + + user1.refresh_from_db() + user2.refresh_from_db() + user3.refresh_from_db() + + assert get_field_values(user1) == expected + assert get_field_values(user2) == expected + assert get_field_values(user3) == unchanged + + +def test_user_update_handler_password(factories, mocker): + user = factories["federation.Actor"](local=True).user + current_password = user.password + + set_password = mocker.spy(users.models.User, "set_password") + + users.handler_update_user([user.username], {"password": "hello"}) + + user.refresh_from_db() + + set_password.assert_called_once_with(user, "hello") + assert user.password != current_password diff --git a/changes/changelog.d/server-cli.feature b/changes/changelog.d/server-cli.feature new file mode 100644 index 0000000000..11eb9ec252 --- /dev/null +++ b/changes/changelog.d/server-cli.feature @@ -0,0 +1 @@ +User management through the server CLI diff --git a/changes/notes.rst b/changes/notes.rst index 96ac3d7651..7a84fce9ac 100644 --- a/changes/notes.rst +++ b/changes/notes.rst @@ -5,3 +5,18 @@ Next release notes Those release notes refer to the current development branch and are reset after each release. + +User management through the server CLI +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +We now support user creation (incl. non-admin accounts), update and removal directly +from the server CLI. Typical use cases include: + +- Changing a user password from the command line +- Creating or updating users from deployments scripts or playbooks +- Removing or granting permissions or upload quota to multiple users at once +- Marking multiple users as inactive + +All user-related commands are available under the ``python manage.py fw users`` namespace. +Please refer to the `Admin documentation <https://docs.funkwhale.audio/admin/commands.html#user-management>`_ for +more information and instructions. diff --git a/docs/admin/commands.rst b/docs/admin/commands.rst index c30a67a999..b771e4fe9e 100644 --- a/docs/admin/commands.rst +++ b/docs/admin/commands.rst @@ -1,6 +1,94 @@ Management commands =================== +User management +--------------- + +It's possible to create, remove and update users directly from the command line. + +This feature is useful if you want to experiment, automate or perform batch actions that +would be too repetitive through the web UI. + +All users-related commands are available under the ``python manage.py fw users`` namespace: + +.. code-block:: sh + + # print subcommands and help + python manage.py fw users --help + + +Creation +^^^^^^^^ + +.. code-block:: sh + + # print help + python manage.py fw users create --help + + # create a user interactively + python manage.py fw users create + + # create a user with a random password + python manage.py fw users create --username alice --email alice@email.host -p "" + + # create a user with password set from an environment variable + export FUNKWHALE_CLI_USER_PASSWORD=securepassword + python manage.py fw users create --username bob --email bob@email.host + +Additional options are available to further configure the user during creation, such as +setting permissions or user quota. Please refer to the command help. + + +Update +^^^^^^ + +.. code-block:: sh + + # print help + python manage.py fw users set --help + + # set upload quota to 500MB for alice + python manage.py fw users set --upload-quota 500 alice + + # disable confirmation prompt with --no-input + python manage.py fw users set --no-input --upload-quota 500 alice + + # make alice and bob staff members + python manage.py fw users set --staff --superuser alice bob + + # remove staff privileges from bob + python manage.py fw users set --no-staff --no-superuser bob + + # give bob moderation permission + python manage.py fw users set --permission-moderation bob + + # reset alice's password + python manage.py fw users set --password "securepassword" alice + + # reset bob's password through an environment variable + export FUNKWHALE_CLI_USER_UPDATE_PASSWORD=newsecurepassword + python manage.py fw users set bob + +Deletion +^^^^^^^^ + +.. code-block:: sh + + # print help + python manage.py fw users rm --help + + # delete bob's account, but keep a reference to their account in the database + # to prevent future signup with the same username + python manage.py fw users rm bob + + # delete alice's account, with no confirmation prompt + python manage.py fw users rm --no-input alice + + # delete alice and bob accounts, including all reference to their account + # (people will be able to signup again with their usernames) + python manage.py fw users rm --hard alice bob + + Pruning library --------------- -- GitLab