From d2254d5bb20675be9858507592fd474012ff01bb Mon Sep 17 00:00:00 2001 From: Ilyas Gasanov Date: Wed, 20 Nov 2024 15:35:38 +0300 Subject: [PATCH 1/2] [DOP-19931] Add manage_superusers script --- .github/workflows/release.yaml | 2 +- .pre-commit-config.yaml | 4 +- .readthedocs.yaml | 2 +- docker-compose.yml | 6 +- docker/entrypoint_backend.sh | 7 + docs/backend/install.rst | 2 + docs/changelog/next_release/137.feature.rst | 1 + syncmaster/backend/handler.py | 2 +- .../{ => scripts}/export_openapi_schema.py | 0 .../backend/scripts/manage_superusers.py | 105 +++++++++++++ tests/conftest.py | 7 +- tests/test_database/test_manage_superusers.py | 104 +++++++++++++ tests/test_unit/conftest.py | 111 ++------------ .../test_groups/test_add_user_to_group.py | 2 +- .../{tests_users => test_users}/__init__.py | 0 .../test_read_user.py | 0 .../test_read_users.py | 0 .../test_users/user_fixtures/__init__.py | 8 + .../test_users/user_fixtures/user_fixture.py | 144 ++++++++++++++++++ 19 files changed, 394 insertions(+), 113 deletions(-) create mode 100644 docs/changelog/next_release/137.feature.rst rename syncmaster/backend/{ => scripts}/export_openapi_schema.py (100%) mode change 100755 => 100644 create mode 100644 syncmaster/backend/scripts/manage_superusers.py create mode 100644 tests/test_database/test_manage_superusers.py rename tests/test_unit/{tests_users => test_users}/__init__.py (100%) rename tests/test_unit/{tests_users => test_users}/test_read_user.py (100%) rename tests/test_unit/{tests_users => test_users}/test_read_users.py (100%) create mode 100644 tests/test_unit/test_users/user_fixtures/__init__.py create mode 100644 tests/test_unit/test_users/user_fixtures/user_fixture.py diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b764de9e..51172041 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -58,7 +58,7 @@ jobs: - name: Generate OpenAPI Schema run: | source .env.local - poetry run python -m syncmaster.backend.export_openapi_schema openapi.json + poetry run python -m syncmaster.backend.scripts.export_openapi_schema openapi.json - name: Fix logo in Readme run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 83fc72a4..22d0e4d2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,13 +40,13 @@ repos: - id: chmod args: ['644'] exclude_types: [shell] - exclude: ^(.*__main__\.py|syncmaster/backend/export_openapi_schema\.py)$ + exclude: ^(.*__main__\.py|syncmaster/backend/scripts/export_openapi_schema\.py)$ - id: chmod args: ['755'] types: [shell] - id: chmod args: ['755'] - files: ^(.*__main__\.py|syncmaster/backend/export_openapi_schema\.py)$ + files: ^(.*__main__\.py|syncmaster/backend/scripts/export_openapi_schema\.py)$ - id: insert-license files: .*\.py$ exclude: ^(syncmaster/backend/dependencies/stub.py|docs/.*\.py|tests/.*\.py)$ diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 6c6d1d3a..0759eb87 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -17,7 +17,7 @@ build: - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH python -m poetry install --no-root --all-extras --with docs --without dev,test - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH python -m poetry show -v - python -m pip list -v - - SYNCMASTER__DATABASE__URL=postgresql+psycopg://fake:fake@127.0.0.1:5432/fake SYNCMASTER__SERVER__SESSION__SECRET_KEY=session_secret_key SYNCMASTER__BROKER__URL=amqp://fake:faket@fake:5672/ SYNCMASTER__CRYPTO_KEY=crypto_key SYNCMASTER__AUTH__ACCESS_TOKEN__SECRET_KEY=fakepython python -m syncmaster.backend.export_openapi_schema docs/_static/openapi.json + - SYNCMASTER__DATABASE__URL=postgresql+psycopg://fake:fake@127.0.0.1:5432/fake SYNCMASTER__SERVER__SESSION__SECRET_KEY=session_secret_key SYNCMASTER__BROKER__URL=amqp://fake:faket@fake:5672/ SYNCMASTER__CRYPTO_KEY=crypto_key SYNCMASTER__AUTH__ACCESS_TOKEN__SECRET_KEY=fakepython python -m syncmaster.backend.scripts.export_openapi_schema docs/_static/openapi.json sphinx: configuration: docs/conf.py diff --git a/docker-compose.yml b/docker-compose.yml index 287f81d6..26b56ba2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,9 +36,11 @@ services: context: . ports: - 8000:8000 - # PROMETHEUS_MULTIPROC_DIR is required for multiple workers, see: - # https://prometheus.github.io/client_python/multiprocess/ environment: + # list here usernames which should be assigned SUPERUSER role on application start + SYNCMASTER__ENTRYPOINT__SUPERUSERS: syncmaster + # PROMETHEUS_MULTIPROC_DIR is required for multiple workers, see: + # https://prometheus.github.io/client_python/multiprocess/ PROMETHEUS_MULTIPROC_DIR: /tmp/prometheus-metrics # tmpfs dir is cleaned up each container restart tmpfs: diff --git a/docker/entrypoint_backend.sh b/docker/entrypoint_backend.sh index 15b9dab7..b25671e1 100755 --- a/docker/entrypoint_backend.sh +++ b/docker/entrypoint_backend.sh @@ -3,5 +3,12 @@ set -e python -m syncmaster.db.migrations upgrade head +# use only by entrypoint +if [[ "x${SYNCMASTER__ENTRYPOINT__SUPERUSERS}" != "x" ]]; then + superusers=$(echo "${SYNCMASTER__ENTRYPOINT__SUPERUSERS}" | tr "," " ") + python -m syncmaster.backend.scripts.manage_superusers add ${superusers} + python -m syncmaster.backend.scripts.manage_superusers list +fi + # exec is required to forward all signals to the main process exec python -m syncmaster.backend --host 0.0.0.0 --port 8000 "$@" diff --git a/docs/backend/install.rst b/docs/backend/install.rst index dc62e641..288f9351 100644 --- a/docs/backend/install.rst +++ b/docs/backend/install.rst @@ -28,6 +28,8 @@ Options can be set via ``.env`` file or ``environment`` section in ``docker-comp After container is started and ready, open http://localhost:8000/docs. +Users listed in ``SYNCMASTER__ENTRYPOINT__SUPERUSERS`` env variable will be automatically promoted to ``SUPERUSER`` role. + Without docker -------------- diff --git a/docs/changelog/next_release/137.feature.rst b/docs/changelog/next_release/137.feature.rst new file mode 100644 index 00000000..588938ed --- /dev/null +++ b/docs/changelog/next_release/137.feature.rst @@ -0,0 +1 @@ +Add new environment variable ``SYNCMASTER__ENTRYPOINT__SUPERUSERS`` to Docker image entrypoint. Here you can pass usernames which should be automatically promoted to SUPERUSER role during backend startup. diff --git a/syncmaster/backend/handler.py b/syncmaster/backend/handler.py index 9ef50252..6e13e30b 100644 --- a/syncmaster/backend/handler.py +++ b/syncmaster/backend/handler.py @@ -63,7 +63,7 @@ def http_exception_handler(request: Request, exc: HTTPException) -> Response: return exception_json_response( status=exc.status_code, content=content, - headers=exc.headers, + headers=exc.headers, # type: ignore[arg-type] ) diff --git a/syncmaster/backend/export_openapi_schema.py b/syncmaster/backend/scripts/export_openapi_schema.py old mode 100755 new mode 100644 similarity index 100% rename from syncmaster/backend/export_openapi_schema.py rename to syncmaster/backend/scripts/export_openapi_schema.py diff --git a/syncmaster/backend/scripts/manage_superusers.py b/syncmaster/backend/scripts/manage_superusers.py new file mode 100644 index 00000000..d59d6ce3 --- /dev/null +++ b/syncmaster/backend/scripts/manage_superusers.py @@ -0,0 +1,105 @@ +#!/bin/env python3 + +# SPDX-FileCopyrightText: 2023-2024 MTS PJSC +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import argparse +import asyncio +import logging + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.future import select + +from syncmaster.backend.middlewares import setup_logging +from syncmaster.backend.settings import BackendSettings as Settings +from syncmaster.db.models.user import User + + +async def add_superusers(session: AsyncSession, usernames: list[str]) -> None: + logging.info("Adding superusers:") + result = await session.execute(select(User).where(User.username.in_(usernames)).order_by(User.username)) + users = result.scalars().all() + + not_found = set(usernames) + for user in users: + user.is_superuser = True + logging.info(" %r", user.username) + not_found.discard(user.username) + + if not_found: + for username in not_found: + session.add(User(username=username, email=f"{username}@mts.ru", is_active=True, is_superuser=True)) + logging.info(" %r (new user)", username) + + await session.commit() + logging.info("Done.") + + +async def remove_superusers(session: AsyncSession, usernames: list[str]) -> None: + logging.info("Removing superusers:") + result = await session.execute(select(User).where(User.username.in_(usernames)).order_by(User.username)) + users = result.scalars().all() + + not_found = set(usernames) + for user in users: + logging.info(" %r", user.username) + user.is_superuser = False + not_found.discard(user.username) + + if not_found: + logging.info("Not found:") + for username in not_found: + logging.info(" %r", username) + + await session.commit() + logging.info("Done.") + + +async def list_superusers(session: AsyncSession) -> None: + result = await session.execute(select(User).filter_by(is_superuser=True).order_by(User.username)) + superusers = result.scalars().all() + logging.info("Listing users with SUPERUSER role:") + for superuser in superusers: + logging.info(" %r", superuser.username) + logging.info("Done.") + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Manage superusers.") + subparsers = parser.add_subparsers(dest="command", required=True) + + parser_add = subparsers.add_parser("add", help="Add superuser privileges to users") + parser_add.add_argument("usernames", nargs="+", help="Usernames to add as superusers") + parser_add.set_defaults(func=add_superusers) + + parser_remove = subparsers.add_parser("remove", help="Remove superuser privileges from users") + parser_remove.add_argument("usernames", nargs="+", help="Usernames to remove from superusers") + parser_remove.set_defaults(func=remove_superusers) + + parser_list = subparsers.add_parser("list", help="List all superusers") + parser_list.set_defaults(func=list_superusers) + + return parser + + +async def main(args: argparse.Namespace, session: AsyncSession) -> None: + async with session: + if args.command == "list": + # 'list' command does not take additional arguments + await args.func(session) + else: + await args.func(session, args.usernames) + + +if __name__ == "__main__": + settings = Settings() + if settings.logging.setup: + setup_logging(settings.logging.get_log_config_path()) + + engine = create_async_engine(settings.database.url) + SessionLocal = async_sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession) + parser = create_parser() + args = parser.parse_args() + session = SessionLocal() + asyncio.run(main(args, session)) diff --git a/tests/conftest.py b/tests/conftest.py index b6308b38..31df7a3f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ import logging import os import time -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable from pathlib import Path import pytest @@ -33,6 +33,7 @@ pytest_plugins = [ "tests.test_unit.test_transfers.transfer_fixtures", "tests.test_unit.test_auth.auth_fixtures", + "tests.test_unit.test_users.user_fixtures", "tests.test_unit.test_runs.run_fixtures", "tests.test_unit.test_connections.connection_fixtures", "tests.test_unit.test_scheduler.scheduler_fixtures", @@ -46,8 +47,8 @@ def access_token_settings(settings: Settings) -> JWTSettings: @pytest.fixture -def access_token_factory(access_token_settings: JWTSettings): - def _generate_access_token(user_id): +def access_token_factory(access_token_settings: JWTSettings) -> Callable[[int], str]: + def _generate_access_token(user_id: int) -> str: return sign_jwt( {"user_id": user_id, "exp": time.time() + 1000}, access_token_settings.secret_key.get_secret_value(), diff --git a/tests/test_database/test_manage_superusers.py b/tests/test_database/test_manage_superusers.py new file mode 100644 index 00000000..45f9760e --- /dev/null +++ b/tests/test_database/test_manage_superusers.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import logging + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from syncmaster.backend.scripts.manage_superusers import ( + add_superusers, + list_superusers, + remove_superusers, +) +from syncmaster.db.models.user import User +from tests.mocks import MockUser + +pytestmark = [pytest.mark.asyncio, pytest.mark.backend] + + +@pytest.mark.parametrize("simple_users", [10], indirect=True) +async def test_add_superusers(caplog, session: AsyncSession, simple_users: list[MockUser]): + expected_superusers = [user.username for user in simple_users[:5]] + expected_not_superusers = [user.username for user in simple_users[5:]] + + with caplog.at_level(logging.INFO): + await add_superusers(session, expected_superusers) + + for username in expected_superusers: + assert repr(username) in caplog.text + + for username in expected_not_superusers: + assert repr(username) not in caplog.text + + superusers_query = select(User).where(User.username.in_(expected_superusers)) + superusers_query_result = await session.execute(superusers_query) + superusers = superusers_query_result.scalars().all() + + assert set(expected_superusers) == {user.username for user in superusers} + for superuser in superusers: + assert superuser.is_superuser + + not_superusers_query = select(User).where(User.username.in_(expected_not_superusers)) + not_superusers_query_result = await session.execute(not_superusers_query) + not_superusers = not_superusers_query_result.scalars().all() + + assert set(expected_not_superusers) == {user.username for user in not_superusers} + for user in not_superusers: + assert not user.is_superuser + + +@pytest.mark.parametrize("simple_users", [10], indirect=True) +async def test_remove_superusers(caplog, session: AsyncSession, simple_users: list[MockUser]): + # users 0 and 1 will be superusers, 2-10 will not + to_create = [user.username for user in simple_users[:5]] + to_delete = [user.username for user in simple_users[2:]] + + expected_superusers = [user.username for user in simple_users[:2]] + expected_not_superusers = [user.username for user in simple_users[2:]] + + await add_superusers(session, to_create) + + caplog.clear() + with caplog.at_level(logging.INFO): + await remove_superusers(session, to_delete) + + for username in expected_superusers: + assert repr(username) not in caplog.text + + for username in expected_not_superusers: + assert repr(username) in caplog.text + + superusers_query = select(User).where(User.username.in_(expected_superusers)) + superusers_query_result = await session.execute(superusers_query) + superusers = superusers_query_result.scalars().all() + + assert set(expected_superusers) == {user.username for user in superusers} + for superuser in superusers: + assert superuser.is_superuser + + not_superusers_query = select(User).where(User.username.in_(expected_not_superusers)) + not_superusers_query_result = await session.execute(not_superusers_query) + not_superusers = not_superusers_query_result.scalars().all() + + assert set(expected_not_superusers) == {user.username for user in not_superusers} + for user in not_superusers: + assert not user.is_superuser + + +@pytest.mark.parametrize("simple_users", [10], indirect=True) +async def test_list_superusers(caplog, session: AsyncSession, simple_users: list[MockUser]): + expected_superusers = [user.username for user in simple_users[:5]] + expected_not_superusers = [user.username for user in simple_users[5:]] + + await add_superusers(session, expected_superusers) + + caplog.clear() + with caplog.at_level(logging.INFO): + await list_superusers(session) + + for username in expected_superusers: + assert repr(username) in caplog.text + + for username in expected_not_superusers: + assert repr(username) not in caplog.text diff --git a/tests/test_unit/conftest.py b/tests/test_unit/conftest.py index 1fde63c4..0c0f8925 100644 --- a/tests/test_unit/conftest.py +++ b/tests/test_unit/conftest.py @@ -1,4 +1,5 @@ import secrets +from collections.abc import AsyncGenerator import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession @@ -19,7 +20,6 @@ create_group, create_queue, create_user, - create_user_cm, ) ALLOWED_SOURCES = "'hive', 'oracle', 'postgres', 'hdfs', 's3'" @@ -81,100 +81,7 @@ async def add_user_to_group( @pytest_asyncio.fixture -async def superuser(session: AsyncSession, access_token_factory) -> MockUser: - async with create_user_cm(session, username="superuser", is_active=True, is_superuser=True) as user: - token = access_token_factory(user.id) - yield MockUser( - user=user, - auth_token=token, - role=UserTestRoles.Superuser, - ) - - -@pytest_asyncio.fixture -async def simple_user(session: AsyncSession, access_token_factory) -> MockUser: - async with create_user_cm(session, username="simple_user", is_active=True) as user: - token = access_token_factory(user.id) - yield MockUser( - user=user, - auth_token=token, - role=UserTestRoles.Developer, - ) - - -@pytest_asyncio.fixture -async def inactive_user(session: AsyncSession, access_token_factory) -> MockUser: - async with create_user_cm(session, username="inactive_user") as user: - access_token_factory(user.id) - yield MockUser( - user=user, - auth_token=access_token_factory(user.id), - role=UserTestRoles.Developer, - ) - - -@pytest_asyncio.fixture -async def deleted_user(session: AsyncSession, access_token_factory) -> MockUser: - async with create_user_cm( - session, - username="deleted_user", - is_deleted=True, - ) as user: - token = access_token_factory(user.id) - yield MockUser( - user=user, - auth_token=token, - role=UserTestRoles.Developer, - ) - - -@pytest_asyncio.fixture -async def user_with_many_roles(session: AsyncSession, simple_user: MockUser, access_token_factory) -> MockUser: - user = await create_user( - session=session, - username="multi_role_user", - is_active=True, - ) - - roles = [ - UserTestRoles.Owner, - UserTestRoles.Maintainer, - UserTestRoles.Developer, - UserTestRoles.Guest, - ] - - groups = [] - - for role in roles: - group = await create_group( - session=session, - name=f"group_for_{role}", - owner_id=user.id if role == UserTestRoles.Owner else simple_user.user.id, - ) - - if role != UserTestRoles.Owner: - await add_user_to_group(user=user, group_id=group.id, session=session, role=role) - groups.append(group) - - await session.commit() - - token = access_token_factory(user.id) - mock_user = MockUser( - user=user, - auth_token=token, - ) - - yield mock_user - - for group in groups: - await session.delete(group) - - await session.delete(user) - await session.commit() - - -@pytest_asyncio.fixture -async def empty_group(session: AsyncSession, access_token_factory) -> MockGroup: +async def empty_group(session: AsyncSession, access_token_factory) -> AsyncGenerator[MockGroup, None]: owner = await create_user( session=session, username="empty_group_owner", @@ -201,7 +108,7 @@ async def empty_group(session: AsyncSession, access_token_factory) -> MockGroup: @pytest_asyncio.fixture -async def group(session: AsyncSession, access_token_factory) -> MockGroup: +async def group(session: AsyncSession, access_token_factory) -> AsyncGenerator[MockGroup, None]: owner = await create_user( session=session, username="notempty_group_owner", @@ -246,7 +153,7 @@ async def group(session: AsyncSession, access_token_factory) -> MockGroup: async def mock_group( session: AsyncSession, access_token_factory, -): +) -> AsyncGenerator[MockGroup, None]: group_owner = await create_user( session=session, username=f"{secrets.token_hex(5)}_group_connection_owner", @@ -295,7 +202,7 @@ async def mock_group( async def group_queue( session: AsyncSession, mock_group: MockGroup, -) -> Queue: +) -> AsyncGenerator[Queue, None]: queue = await create_queue( session=session, name=f"{secrets.token_hex(5)}_test_queue", @@ -312,7 +219,7 @@ async def group_queue( async def mock_queue( session: AsyncSession, group: MockGroup, -) -> Queue: +) -> AsyncGenerator[Queue, None]: queue = await create_queue( session=session, name=f"{secrets.token_hex(5)}_test_queue", @@ -339,7 +246,7 @@ async def two_group_connections( settings: Settings, mock_group: MockGroup, group_queue: Queue, # do not delete -) -> tuple[MockConnection, MockConnection]: +) -> AsyncGenerator[tuple[MockConnection, MockConnection], None]: connection1 = await create_connection( session=session, name=f"{secrets.token_hex(5)}_group_for_group_connection", @@ -421,7 +328,7 @@ async def group_connection_with_same_name_maintainer_plus( group_connection: MockConnection, role_maintainer_plus: UserTestRoles, role_maintainer_or_below_without_guest: UserTestRoles, -) -> str: +) -> AsyncGenerator[str, None]: user = group_connection.owner_group.get_member_of_role(role_maintainer_plus) await add_user_to_group( @@ -454,7 +361,7 @@ async def group_connection_with_same_name( settings: Settings, empty_group: MockGroup, group_connection: MockConnection, -) -> None: +) -> AsyncGenerator[None, None]: connection = await create_connection( session=session, name=group_connection.connection.name, diff --git a/tests/test_unit/test_groups/test_add_user_to_group.py b/tests/test_unit/test_groups/test_add_user_to_group.py index 569aabb0..bfa79909 100644 --- a/tests/test_unit/test_groups/test_add_user_to_group.py +++ b/tests/test_unit/test_groups/test_add_user_to_group.py @@ -337,7 +337,7 @@ async def test_owner_add_unknown_user_to_group_error( } -async def test_add_exiting_owner_as_a_group_member( +async def test_add_existing_owner_as_a_group_member( client: AsyncClient, empty_group: MockGroup, role_maintainer_or_below: UserTestRoles, diff --git a/tests/test_unit/tests_users/__init__.py b/tests/test_unit/test_users/__init__.py similarity index 100% rename from tests/test_unit/tests_users/__init__.py rename to tests/test_unit/test_users/__init__.py diff --git a/tests/test_unit/tests_users/test_read_user.py b/tests/test_unit/test_users/test_read_user.py similarity index 100% rename from tests/test_unit/tests_users/test_read_user.py rename to tests/test_unit/test_users/test_read_user.py diff --git a/tests/test_unit/tests_users/test_read_users.py b/tests/test_unit/test_users/test_read_users.py similarity index 100% rename from tests/test_unit/tests_users/test_read_users.py rename to tests/test_unit/test_users/test_read_users.py diff --git a/tests/test_unit/test_users/user_fixtures/__init__.py b/tests/test_unit/test_users/user_fixtures/__init__.py new file mode 100644 index 00000000..f430f258 --- /dev/null +++ b/tests/test_unit/test_users/user_fixtures/__init__.py @@ -0,0 +1,8 @@ +from tests.test_unit.test_users.user_fixtures.user_fixture import ( + deleted_user, + inactive_user, + simple_user, + simple_users, + superuser, + user_with_many_roles, +) diff --git a/tests/test_unit/test_users/user_fixtures/user_fixture.py b/tests/test_unit/test_users/user_fixtures/user_fixture.py new file mode 100644 index 00000000..4d289c6a --- /dev/null +++ b/tests/test_unit/test_users/user_fixtures/user_fixture.py @@ -0,0 +1,144 @@ +from collections.abc import AsyncGenerator, Callable + +import pytest_asyncio +from pytest import FixtureRequest +from sqlalchemy.ext.asyncio import AsyncSession + +from tests.mocks import MockUser, UserTestRoles +from tests.test_unit.conftest import add_user_to_group +from tests.test_unit.utils import create_group, create_user, create_user_cm + + +@pytest_asyncio.fixture +async def superuser(session: AsyncSession, access_token_factory) -> AsyncGenerator[MockUser, None]: + async with create_user_cm(session, username="superuser", is_active=True, is_superuser=True) as user: + token = access_token_factory(user.id) + yield MockUser( + user=user, + auth_token=token, + role=UserTestRoles.Superuser, + ) + + +@pytest_asyncio.fixture +async def simple_user(session: AsyncSession, access_token_factory) -> AsyncGenerator[MockUser, None]: + async with create_user_cm(session, username="simple_user", is_active=True) as user: + token = access_token_factory(user.id) + yield MockUser( + user=user, + auth_token=token, + role=UserTestRoles.Developer, + ) + + +@pytest_asyncio.fixture(params=[5]) +async def simple_users( + request: FixtureRequest, + session: AsyncSession, + access_token_factory: Callable[[int], str], +) -> AsyncGenerator[list[MockUser], None]: + size = request.param + users = [] + for i in range(size): + username = f"simple_user_{i}" + user = await create_user( + session=session, + username=username, + email=f"{username}@user.user", + first_name=f"{username}_first", + middle_name=f"{username}_middle", + last_name=f"{username}_last", + is_active=True, + is_superuser=False, + is_deleted=False, + ) + token = access_token_factory(user.id) + mock_user = MockUser( + user=user, + auth_token=token, + role=UserTestRoles.Developer, + ) + users.append(mock_user) + + await session.commit() + + yield users + + for user in users: + await session.delete(user) + await session.commit() + + +@pytest_asyncio.fixture +async def inactive_user(session: AsyncSession, access_token_factory) -> AsyncGenerator[MockUser, None]: + async with create_user_cm(session, username="inactive_user") as user: + access_token_factory(user.id) + yield MockUser( + user=user, + auth_token=access_token_factory(user.id), + role=UserTestRoles.Developer, + ) + + +@pytest_asyncio.fixture +async def deleted_user(session: AsyncSession, access_token_factory) -> AsyncGenerator[MockUser, None]: + async with create_user_cm( + session, + username="deleted_user", + is_deleted=True, + ) as user: + token = access_token_factory(user.id) + yield MockUser( + user=user, + auth_token=token, + role=UserTestRoles.Developer, + ) + + +@pytest_asyncio.fixture +async def user_with_many_roles( + session: AsyncSession, + simple_user: MockUser, + access_token_factory, +) -> AsyncGenerator[MockUser, None]: + user = await create_user( + session=session, + username="multi_role_user", + is_active=True, + ) + + roles = [ + UserTestRoles.Owner, + UserTestRoles.Maintainer, + UserTestRoles.Developer, + UserTestRoles.Guest, + ] + + groups = [] + + for role in roles: + group = await create_group( + session=session, + name=f"group_for_{role}", + owner_id=user.id if role == UserTestRoles.Owner else simple_user.user.id, + ) + + if role != UserTestRoles.Owner: + await add_user_to_group(user=user, group_id=group.id, session=session, role=role) + groups.append(group) + + await session.commit() + + token = access_token_factory(user.id) + mock_user = MockUser( + user=user, + auth_token=token, + ) + + yield mock_user + + for group in groups: + await session.delete(group) + + await session.delete(user) + await session.commit() From e9862ff34a5e7ad54d1926c6e1059efce24ef0e2 Mon Sep 17 00:00:00 2001 From: Ilyas Gasanov Date: Wed, 20 Nov 2024 16:27:36 +0300 Subject: [PATCH 2/2] [DOP-19931] Add manage_superusers script --- docker-compose.yml | 2 +- docker/entrypoint_backend.sh | 1 - docs/backend/install.rst | 21 +++++++++++++++++++ tests/test_database/test_manage_superusers.py | 8 +++---- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 26b56ba2..0e49b698 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,7 @@ services: - 8000:8000 environment: # list here usernames which should be assigned SUPERUSER role on application start - SYNCMASTER__ENTRYPOINT__SUPERUSERS: syncmaster + SYNCMASTER__ENTRYPOINT__SUPERUSERS: admin # PROMETHEUS_MULTIPROC_DIR is required for multiple workers, see: # https://prometheus.github.io/client_python/multiprocess/ PROMETHEUS_MULTIPROC_DIR: /tmp/prometheus-metrics diff --git a/docker/entrypoint_backend.sh b/docker/entrypoint_backend.sh index b25671e1..181c41f8 100755 --- a/docker/entrypoint_backend.sh +++ b/docker/entrypoint_backend.sh @@ -3,7 +3,6 @@ set -e python -m syncmaster.db.migrations upgrade head -# use only by entrypoint if [[ "x${SYNCMASTER__ENTRYPOINT__SUPERUSERS}" != "x" ]]; then superusers=$(echo "${SYNCMASTER__ENTRYPOINT__SUPERUSERS}" | tr "," " ") python -m syncmaster.backend.scripts.manage_superusers add ${superusers} diff --git a/docs/backend/install.rst b/docs/backend/install.rst index 288f9351..4ba726f8 100644 --- a/docs/backend/install.rst +++ b/docs/backend/install.rst @@ -28,8 +28,29 @@ Options can be set via ``.env`` file or ``environment`` section in ``docker-comp After container is started and ready, open http://localhost:8000/docs. +Managing superusers +^^^^^^^^^^^^^^^^^^^ + Users listed in ``SYNCMASTER__ENTRYPOINT__SUPERUSERS`` env variable will be automatically promoted to ``SUPERUSER`` role. +Adding superusers: + +.. code-block:: console + + $ python -m syncmaster.backend.scripts.manage_superusers add + +Removing superusers: + +.. code-block:: console + + $ python -m syncmaster.backend.scripts.manage_superusers remove + +Viewing list of superusers: + +.. code-block:: console + + $ python -m syncmaster.backend.scripts.manage_superusers list + Without docker -------------- diff --git a/tests/test_database/test_manage_superusers.py b/tests/test_database/test_manage_superusers.py index 45f9760e..9ccff3d5 100644 --- a/tests/test_database/test_manage_superusers.py +++ b/tests/test_database/test_manage_superusers.py @@ -50,12 +50,12 @@ async def test_add_superusers(caplog, session: AsyncSession, simple_users: list[ @pytest.mark.parametrize("simple_users", [10], indirect=True) async def test_remove_superusers(caplog, session: AsyncSession, simple_users: list[MockUser]): - # users 0 and 1 will be superusers, 2-10 will not + # users 0-4 will be superusers, 5-10 will not to_create = [user.username for user in simple_users[:5]] - to_delete = [user.username for user in simple_users[2:]] + to_delete = [user.username for user in simple_users[5:]] - expected_superusers = [user.username for user in simple_users[:2]] - expected_not_superusers = [user.username for user in simple_users[2:]] + expected_superusers = [user.username for user in simple_users[:5]] + expected_not_superusers = [user.username for user in simple_users[5:]] await add_superusers(session, to_create)