Server CLI: user management

environments/review-front-340-9n9j9v/deployments/3368
Eliot Berriot 2019-11-25 09:45:53 +01:00
rodzic 900fabae79
commit 654d206033
13 zmienionych plików z 700 dodań i 1 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,3 @@
import logging
logger = logging.getLogger("funkwhale_api.cli")

Wyświetl plik

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

Wyświetl plik

@ -73,3 +73,5 @@ django-storages==1.7.1
boto3<3
unicode-slugify
django-cacheops==4.2
click>=7,<8

Wyświetl plik

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1 @@
User management through the server CLI

Wyświetl plik

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

Wyświetl plik

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