1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-15 01:28:30 +00:00

Introduced endpoint to create a new user as admin

This commit is contained in:
Bram Wiepjes 2023-10-23 07:41:34 +00:00
parent db27dcb5ad
commit 3679d09cf4
10 changed files with 507 additions and 36 deletions
backend/src/baserow/core/user
changelog/entries/unreleased/feature
premium/backend
src/baserow_premium
tests/baserow_premium_tests
web-frontend
locales
modules/core/plugins

View file

@ -104,6 +104,55 @@ class UserHandler(metaclass=baserow_trace_methods(tracer)):
except User.DoesNotExist:
raise UserNotFound("The user with the provided parameters is not found.")
def force_create_user(self, email, name, password, **kwargs):
"""
Creates a new user and their profile.
:param email: The username/email of the new user.
:param name: The full name of the new user.
:param password: The password of the new user.
:param kwargs: Additional kwargs that must be added when creating the User
object.
:raises UserAlreadyExist: When the user with the provided email already exists.
:raises PasswordDoesNotMatchValidation: When a provided password does not match
password validation.
:raises DeactivatedUserException: When a user with the provided email exists but
has been deactivated.
:return: The newly created user object.
"""
language = settings.LANGUAGE_CODE
if "language" in kwargs:
language = kwargs.pop("language") or settings.LANGUAGE_CODE
email = normalize_email_address(email)
user_query = User.objects.filter(Q(email=email) | Q(username=email))
if user_query.exists():
user = user_query.first()
if user.is_active:
raise UserAlreadyExist(f"A user with email {email} already exists.")
else:
raise DeactivatedUserException(
f"User with email {email} has been deactivated."
)
user = User(first_name=name, email=email, username=email, **kwargs)
if password is not None:
try:
validate_password(password, user)
except ValidationError as e:
raise PasswordDoesNotMatchValidation(e.messages)
user.set_password(password)
user.save()
# Immediately create the one-to-one relationship with the user profile
# so we can safely use it everywhere else in the code.
UserProfile.objects.create(user=user, language=language)
return user
def create_user(
self,
name: str,
@ -135,24 +184,11 @@ class UserHandler(metaclass=baserow_trace_methods(tracer)):
:raises WorkspaceInvitationEmailMismatch: If the workspace invitation email
does not match the one of the user.
:raises SignupDisabledError: If signing up is disabled.
:raises PasswordDoesNotMatchValidation: When a provided password does not match
password validation.
:return: The user object.
"""
core_handler = CoreHandler()
email = normalize_email_address(email)
user_query = User.objects.filter(Q(email=email) | Q(username=email))
if user_query.exists():
user = user_query.first()
if user.is_active:
raise UserAlreadyExist(f"A user with email {email} already exists.")
else:
raise DeactivatedUserException(
f"User with email {email} has been deactivated."
)
workspace_invitation = None
workspace_user = None
@ -176,32 +212,21 @@ class UserHandler(metaclass=baserow_trace_methods(tracer)):
if not (allow_new_signups or allow_signup_for_invited_user):
raise DisabledSignupError("Sign up is disabled.")
user = User(first_name=name, email=email, username=email)
if password is not None:
try:
validate_password(password, user)
except ValidationError as e:
raise PasswordDoesNotMatchValidation(e.messages)
user.set_password(password)
if not User.objects.exists():
user = self.force_create_user(
email=email,
name=name,
password=password,
# This is the first ever user created in this baserow instance and
# therefore the administrator user, lets give them staff rights so they
# can set baserow wide settings.
user.is_staff = True
is_staff=not User.objects.exists(),
language=language,
)
if instance_settings.show_admin_signup_page:
instance_settings.show_admin_signup_page = False
instance_settings.save()
user.save()
# Immediately create the one-to-one relationship with the user profile
# so we can safely use it everywhere else in the code.
language = language or settings.LANGUAGE_CODE
UserProfile.objects.create(user=user, language=language)
# If we have an invitation to a workspace, then accept it.
if workspace_invitation_token:
workspace_user = core_handler.accept_workspace_invitation(

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Introduced endpoint to create a new user as admin.",
"issue_number": null,
"bullet_points": [],
"created_at": "2023-10-12"
}

View file

@ -3,6 +3,7 @@ from typing import Optional
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db.models import Q
from baserow_premium.admin.users.exceptions import (
CannotDeactivateYourselfException,
@ -14,12 +15,54 @@ from baserow_premium.license.handler import LicenseHandler
from baserow.core.exceptions import IsNotAdminError
from baserow.core.signals import before_user_deleted
from baserow.core.user.exceptions import PasswordDoesNotMatchValidation
from baserow.core.user.exceptions import (
PasswordDoesNotMatchValidation,
UserAlreadyExist,
)
from baserow.core.user.handler import UserHandler
from baserow.core.user.utils import normalize_email_address
User = get_user_model()
class UserAdminHandler:
def create_user(
self,
requesting_user: User,
username: str,
name: str,
password: str,
is_active: bool = True,
is_staff: bool = False,
):
"""
Creates a new user with the provided values if the requesting user has admin
access. The user will be created, even if the signups are disabled.
:param requesting_user: The user who is making the request to creata a user, the
user must be a staff member or else an exception will be raised.
:param username: New username/email to set for the user.
:param name: New name to set on the user.
:param password: New password to securely set for the user.
:param is_staff: Value used to set if the user is an admin or not.
:param is_active: Value to disable or enable login for the user.
"""
LicenseHandler.raise_if_user_doesnt_have_feature_instance_wide(
PREMIUM, requesting_user
)
self._raise_if_not_permitted(requesting_user)
user = UserHandler().force_create_user(
email=username,
name=name,
password=password,
is_staff=is_staff,
is_active=is_active,
)
return user
def update_user(
self,
requesting_user: User,
@ -46,6 +89,7 @@ class UserAdminHandler:
:param username: Optional new username/email to set for the user.
:raises PasswordDoesNotMatchValidation: When the provided password value is not
a valid password.
:raises UserAlreadyExist: If a user with that username already exists.
"""
LicenseHandler.raise_if_user_doesnt_have_feature_instance_wide(
@ -74,8 +118,17 @@ class UserAdminHandler:
if name is not None:
user.first_name = name
if username is not None:
user.email = username
user.username = username
email = normalize_email_address(username)
user_query = User.objects.filter(
Q(email=email) | Q(username=email), ~Q(id=user.id)
)
if email != user.email and user_query.exists():
raise UserAlreadyExist(
f"A user with the username {email} already exists."
)
user.email = email
user.username = email
user.save()
return user

View file

@ -17,3 +17,9 @@ USER_ADMIN_UNKNOWN_USER = (
HTTP_400_BAD_REQUEST,
"Unknown user supplied.",
)
USER_ADMIN_ALREADY_EXISTS = (
"USER_ADMIN_ALREADY_EXISTS",
HTTP_400_BAD_REQUEST,
"A user with that username/email already exists.",
)

View file

@ -66,6 +66,27 @@ class UserAdminResponseSerializer(ModelSerializer):
extra_kwargs = _USER_ADMIN_SERIALIZER_API_DOC_KWARGS
class UserAdminCreateSerializer(
UnknownFieldRaisesExceptionSerializerMixin, ModelSerializer
):
"""
Serializes a request body for creating a new user. Do not use for returning user
data as the password will be returned also.
"""
# Max length set to match django user models first_name fields max length
name = CharField(source="first_name", max_length=150, required=True)
username = EmailField(required=True)
password = CharField(validators=[password_validation], required=True)
class Meta:
model = User
fields = ("username", "name", "is_active", "is_staff", "password")
extra_kwargs = {
**_USER_ADMIN_SERIALIZER_API_DOC_KWARGS,
}
class UserAdminUpdateSerializer(
UnknownFieldRaisesExceptionSerializerMixin, ModelSerializer
):

View file

@ -8,11 +8,13 @@ from baserow_premium.admin.users.exceptions import (
)
from baserow_premium.admin.users.handler import UserAdminHandler
from baserow_premium.api.admin.users.errors import (
USER_ADMIN_ALREADY_EXISTS,
USER_ADMIN_CANNOT_DEACTIVATE_SELF,
USER_ADMIN_CANNOT_DELETE_SELF,
USER_ADMIN_UNKNOWN_USER,
)
from baserow_premium.api.admin.users.serializers import (
UserAdminCreateSerializer,
UserAdminResponseSerializer,
UserAdminUpdateSerializer,
)
@ -32,6 +34,7 @@ from baserow.api.decorators import map_exceptions, validate_body
from baserow.api.schemas import get_error_schema
from baserow.api.user.schemas import authenticate_user_schema
from baserow.api.user.serializers import get_all_user_data_serialized
from baserow.core.user.exceptions import DeactivatedUserException, UserAlreadyExist
from baserow.core.user.utils import generate_session_tokens_for_user
from .serializers import BaserowImpersonateAuthTokenSerializer
@ -71,6 +74,41 @@ class UsersAdminView(AdminListingView):
)
return super().get(request)
@extend_schema(
tags=["Admin"],
request=UserAdminCreateSerializer,
operation_id="admin_create_user",
description=(
"Creates and returns a new user if the requesting user is staff. This "
"works even if new signups are disabled. \n\nThis is a **premium** feature."
),
responses={
200: UserAdminResponseSerializer(),
400: get_error_schema(
[
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_FEATURE_NOT_AVAILABLE",
"USER_ADMIN_ALREADY_EXISTS",
]
),
},
)
@validate_body(UserAdminCreateSerializer)
@map_exceptions(
{
UserAlreadyExist: USER_ADMIN_ALREADY_EXISTS,
DeactivatedUserException: USER_ADMIN_ALREADY_EXISTS,
}
)
@transaction.atomic
def post(self, request, data) -> Response:
"""Creates a new user with the supplied attributes."""
handler = UserAdminHandler()
user = handler.create_user(request.user, **data)
return Response(UserAdminResponseSerializer(user).data)
class UserAdminView(APIView):
permission_classes = (IsAdminUser,)
@ -97,6 +135,7 @@ class UserAdminView(APIView):
"ERROR_REQUEST_BODY_VALIDATION",
"USER_ADMIN_CANNOT_DEACTIVATE_SELF",
"USER_ADMIN_UNKNOWN_USER",
"USER_ADMIN_ALREADY_EXISTS",
"ERROR_FEATURE_NOT_AVAILABLE",
]
),
@ -108,6 +147,7 @@ class UserAdminView(APIView):
{
CannotDeactivateYourselfException: USER_ADMIN_CANNOT_DEACTIVATE_SELF,
UserDoesNotExistException: USER_ADMIN_UNKNOWN_USER,
UserAlreadyExist: USER_ADMIN_ALREADY_EXISTS,
}
)
@transaction.atomic

View file

@ -11,7 +11,10 @@ from baserow_premium.admin.users.handler import UserAdminHandler
from baserow_premium.license.exceptions import FeaturesNotAvailableError
from baserow.core.exceptions import IsNotAdminError
from baserow.core.user.exceptions import PasswordDoesNotMatchValidation
from baserow.core.user.exceptions import (
PasswordDoesNotMatchValidation,
UserAlreadyExist,
)
User = get_user_model()
invalid_passwords = [
@ -166,6 +169,128 @@ def test_admin_can_deactive_and_unstaff_other_users(premium_data_fixture):
assert not active_user.is_active
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_create_user(premium_data_fixture):
handler = UserAdminHandler()
admin_user = premium_data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
has_active_premium_license=True,
)
user = handler.create_user(
requesting_user=admin_user,
username="new@test.nl",
name="Test",
password="password",
is_active=True,
is_staff=True,
)
user = User.objects.get(pk=user.id)
assert user.username == "new@test.nl"
assert user.email == "new@test.nl"
assert user.first_name == "Test"
assert user.is_active is True
assert user.is_staff is True
assert user.check_password("password")
assert user.profile.id
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_create_user_as_non_admin(premium_data_fixture):
handler = UserAdminHandler()
admin_user = premium_data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=False,
has_active_premium_license=True,
)
with pytest.raises(IsNotAdminError):
handler.create_user(
requesting_user=admin_user,
username="new@test.nl",
name="Test",
password="password",
is_active=True,
is_staff=True,
)
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_create_user_without_license(premium_data_fixture):
handler = UserAdminHandler()
admin_user = premium_data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=False,
has_active_premium_license=False,
)
with pytest.raises(FeaturesNotAvailableError):
handler.create_user(
requesting_user=admin_user,
username="new@test.nl",
name="Test",
password="password",
is_active=True,
is_staff=True,
)
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_create_user_that_already_exists(premium_data_fixture):
handler = UserAdminHandler()
admin_user = premium_data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
has_active_premium_license=True,
)
with pytest.raises(UserAlreadyExist):
handler.create_user(
requesting_user=admin_user,
username="test@test.nl",
name="Test",
password="password",
is_active=True,
is_staff=True,
)
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_create_user_with_invalid_password(premium_data_fixture):
handler = UserAdminHandler()
admin_user = premium_data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
has_active_premium_license=True,
)
with pytest.raises(PasswordDoesNotMatchValidation):
handler.create_user(
requesting_user=admin_user,
username="test2@test.nl",
name="Test",
password="t",
is_active=True,
is_staff=True,
)
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_updating_a_users_password_uses_djangos_built_in_smart_set_password(
@ -357,3 +482,39 @@ def test_raises_exception_when_updating_an_unknown_user(premium_data_fixture):
)
with pytest.raises(UserDoesNotExistException):
handler.update_user(admin_user, 99999, username="new_password")
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_raises_exception_when_changing_to_an_existing_user(premium_data_fixture):
premium_data_fixture.create_user(email="existing@test.nl")
handler = UserAdminHandler()
admin_user = premium_data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
is_active=True,
has_active_premium_license=True,
)
with pytest.raises(UserAlreadyExist):
handler.update_user(admin_user, admin_user.id, username="existing@test.nl")
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_does_not_raise_exception_when_changing_to_same_username(premium_data_fixture):
handler = UserAdminHandler()
admin_user = premium_data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
is_active=True,
has_active_premium_license=True,
)
assert (
handler.update_user(admin_user, admin_user.id, username="test@test.nl").email
== "test@test.nl"
)

View file

@ -1,11 +1,13 @@
import json
from django.contrib.auth import get_user_model
from django.shortcuts import reverse
from django.test.utils import override_settings
from django.utils import timezone
from django.utils.datetime_safe import datetime
import pytest
from freezegun import freeze_time
from rest_framework.status import (
HTTP_200_OK,
HTTP_204_NO_CONTENT,
@ -20,6 +22,7 @@ from baserow.core.models import (
WORKSPACE_USER_PERMISSION_MEMBER,
)
User = get_user_model()
invalid_passwords = [
"a",
"ab",
@ -554,6 +557,132 @@ def test_non_admin_cannot_patch_user_without_premium_license(
assert non_admin_user.email == "test@test.nl"
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_admin_cannot_create_user_without_body(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
has_active_premium_license=True,
)
url = reverse("api:premium:admin:users:list")
response = api_client.post(
url,
{},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
user.refresh_from_db()
assert response.status_code == HTTP_400_BAD_REQUEST
response_json = response.json()
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
assert response_json["detail"]["name"][0]["code"] == "required"
assert response_json["detail"]["password"][0]["code"] == "required"
assert response_json["detail"]["username"][0]["code"] == "required"
assert User.objects.all().count() == 1
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_admin_cannot_create_user_without_license(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
has_active_premium_license=False,
)
url = reverse("api:premium:admin:users:list")
response = api_client.post(
url,
{"username": "test2@test.nl", "password": "Test1234", "name": "Test1"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
user.refresh_from_db()
print(response.json())
assert response.status_code == HTTP_402_PAYMENT_REQUIRED
response_json = response.json()
assert response_json["error"] == "ERROR_FEATURE_NOT_AVAILABLE"
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_admin_cannot_create_user_that_already_exists(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
has_active_premium_license=True,
)
premium_data_fixture.create_user(email="test2@test.nl")
url = reverse("api:premium:admin:users:list")
response = api_client.post(
url,
{"username": "test2@test.nl", "password": "Test1234", "name": "Test1"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
user.refresh_from_db()
assert response.status_code == HTTP_400_BAD_REQUEST
response_json = response.json()
assert response_json["error"] == "USER_ADMIN_ALREADY_EXISTS"
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_admin_can_create_user(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
has_active_premium_license=True,
)
url = reverse("api:premium:admin:users:list")
with freeze_time("2020-01-02 12:00"):
response = api_client.post(
url,
{
"username": "test2@test.nl",
"password": "Test1234",
"name": "Test1",
"is_staff": True,
"is_active": True,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
user = User.objects.all().last()
assert response.status_code == HTTP_200_OK
assert response.json() == {
"date_joined": "2020-01-02T12:00:00Z",
"name": "Test1",
"username": "test2@test.nl",
"groups": [], # GroupDeprecation
"workspaces": [],
"id": user.id,
"is_staff": True,
"is_active": True,
"last_login": None,
}
response = api_client.post(
reverse("api:user:token_auth"),
{"email": "test@test.nl", "password": "password"},
format="json",
)
assert response.status_code == HTTP_200_OK
assert "access_token" in response.json()
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_admin_can_patch_user(api_client, premium_data_fixture):
@ -626,6 +755,29 @@ def test_admin_can_patch_user_without_providing_password(
}
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_admin_update_to_existing_user(api_client, premium_data_fixture):
user_to_change = premium_data_fixture.create_user()
user, token = premium_data_fixture.create_user_and_token(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
has_active_premium_license=True,
)
url = reverse("api:premium:admin:users:edit", kwargs={"user_id": user_to_change.id})
response = api_client.patch(
url,
{"username": "test@test.nl"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == 400
assert response.json()["error"] == "USER_ADMIN_ALREADY_EXISTS"
@pytest.mark.django_db
@override_settings(DEBUG=True)
@pytest.mark.parametrize("invalid_password", invalid_passwords)

View file

@ -283,7 +283,9 @@
"maxLocksPerTransactionExceededDescription": "Baserow attempted to permanently delete the trashed items, but exceeded the available locks specified in `max_locks_per_transaction`.",
"disabledPasswordProviderMessage": "Please use another authentication provider.",
"lastAdminTitle": "Can't remove last workspace admin",
"lastAdminMessage": "A workspace has to have at least one admin."
"lastAdminMessage": "A workspace has to have at least one admin.",
"adminAlreadyExistsTitle": "Can't use that username",
"adminAlreadyExistsDescription": "That username can't be used because it already exists."
},
"importerType": {
"csv": "Import a CSV file",

View file

@ -64,6 +64,10 @@ export class ClientErrorMap {
app.i18n.t('clientHandler.adminCannotDeleteSelfTitle'),
app.i18n.t('clientHandler.adminCannotDeleteSelfDescription')
),
USER_ADMIN_ALREADY_EXISTS: new ResponseErrorMessage(
app.i18n.t('clientHandler.adminAlreadyExistsTitle'),
app.i18n.t('clientHandler.adminAlreadyExistsDescription')
),
ERROR_MAX_FIELD_COUNT_EXCEEDED: new ResponseErrorMessage(
app.i18n.t('clientHandler.maxFieldCountExceededTitle'),
app.i18n.t('clientHandler.maxFieldCountExceededDescription')