Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)$
Expand Down
2 changes: 1 addition & 1 deletion .readthedocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 4 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: admin
# 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:
Expand Down
6 changes: 6 additions & 0 deletions docker/entrypoint_backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@ set -e

python -m syncmaster.db.migrations upgrade head

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 "$@"
23 changes: 23 additions & 0 deletions docs/backend/install.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +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 <username1> <username2>

Removing superusers:

.. code-block:: console

$ python -m syncmaster.backend.scripts.manage_superusers remove <username1> <username2>

Viewing list of superusers:

.. code-block:: console

$ python -m syncmaster.backend.scripts.manage_superusers list

Without docker
--------------

Expand Down
1 change: 1 addition & 0 deletions docs/changelog/next_release/137.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion syncmaster/backend/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)


Expand Down
File renamed without changes.
105 changes: 105 additions & 0 deletions syncmaster/backend/scripts/manage_superusers.py
Original file line number Diff line number Diff line change
@@ -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))
7 changes: 4 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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(),
Expand Down
104 changes: 104 additions & 0 deletions tests/test_database/test_manage_superusers.py
Original file line number Diff line number Diff line change
@@ -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-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[5:]]

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)

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
Loading