diff --git a/Makefile b/Makefile index cb8ad0fe09..e1a6dc9b8c 100644 --- a/Makefile +++ b/Makefile @@ -144,5 +144,12 @@ run-dev-otel run-bash-otel: | start-dev-otel run-dev-attach-otel build-dev: docker build --file=dev/Dockerfile.dev . --tag=openslides-backend-dev +build-dev-fullstack: + DOCKER_BUILDKIT=1 docker build --file=dev/Dockerfile.dev . \ + --build-arg=REQUIREMENTS_FILE=requirements_development_fullstack.txt \ + --build-context pipauth=../openslides-auth-service/libraries/pip-auth \ + --build-context datastore=../openslides-datastore-service \ + --tag=openslides-backend-dev-fullstack + rebuild-dev: - docker build --file=dev/Dockerfile.dev . --tag=openslides-backend-dev --no-cache + docker build --file=dev/Dockerfile.dev . --target development --tag=openslides-backend-dev --no-cache diff --git a/data/example-data.json b/data/example-data.json index 5545126694..8480f4cf2b 100644 --- a/data/example-data.json +++ b/data/example-data.json @@ -1,5 +1,5 @@ { - "_migration_index": 63, + "_migration_index": 64, "gender":{ "1":{ "id": 1, diff --git a/data/initial-data.json b/data/initial-data.json index a219af8dd1..f43e61ee30 100644 --- a/data/initial-data.json +++ b/data/initial-data.json @@ -1,5 +1,5 @@ { - "_migration_index": 63, + "_migration_index": 64, "gender":{ "1":{ "id": 1, diff --git a/dev/Dockerfile.dev b/dev/Dockerfile.dev index 2000f9e75e..a7157c978b 100644 --- a/dev/Dockerfile.dev +++ b/dev/Dockerfile.dev @@ -1,13 +1,9 @@ -FROM python:3.10.13-slim-bookworm +FROM python:3.10.13-slim-bookworm as base RUN apt-get update && apt-get install --yes make git curl ncat vim bash-completion mime-support gcc libpq-dev libmagic1 WORKDIR /app -COPY requirements/ requirements/ -ARG REQUIREMENTS_FILE=requirements_development.txt -RUN . requirements/export_service_commits.sh && pip install --no-cache-dir --requirement requirements/$REQUIREMENTS_FILE - COPY dev/.bashrc . COPY dev/cleanup.sh . @@ -43,3 +39,17 @@ ENV DEFAULT_FROM_EMAIL noreply@example.com STOPSIGNAL SIGKILL ENTRYPOINT ["./entrypoint.sh"] CMD exec python -m debugpy --listen 0.0.0.0:5678 openslides_backend + +FROM base AS development + +COPY requirements/ requirements/ +ARG REQUIREMENTS_FILE=requirements_development.txt +RUN . requirements/export_service_commits.sh && pip install --no-cache-dir --requirement requirements/$REQUIREMENTS_FILE + +FROM base AS development-fullstack + +COPY --from=pipauth / /pip-auth +COPY --from=datastore / /openslides-datastore-service +COPY requirements/ requirements/ +ARG REQUIREMENTS_FILE=requirements_development.txt +RUN . requirements/export_service_commits.sh && pip install --no-cache-dir --requirement requirements/$REQUIREMENTS_FILE diff --git a/dev/dc.local.yml b/dev/dc.local.yml index 131c5112cf..5ddb734193 100644 --- a/dev/dc.local.yml +++ b/dev/dc.local.yml @@ -7,7 +7,7 @@ services: - REQUIREMENTS_FILE=requirements_development_local.txt volumes: - ../../openslides-datastore-service/:/datastore-service - - ../../openslides-auth-service/auth/libraries/pip-auth/:/authlib + - ../../openslides-auth-service/libraries/pip-auth/:/authlib environment: - PYTHONPATH=/app:/datastore-service:/authlib - MYPYPATH=/app:/datastore-service:/authlib @@ -34,6 +34,3 @@ services: vote: build: context: ../../openslides-vote-service - auth: - build: - context: ../../openslides-auth-service diff --git a/dev/docker-compose.dev.yml b/dev/docker-compose.dev.yml index f0150c79f4..4365df50ca 100644 --- a/dev/docker-compose.dev.yml +++ b/dev/docker-compose.dev.yml @@ -29,6 +29,12 @@ services: - CACHE_HOST=redis - DATABASE_HOST=postgres - DATASTORE_LOG_LEVEL=CRITICAL + - OPENSLIDES_KEYCLOAK_URL=http://keycloak:8080/idp + - OPENSLIDES_AUTH_REALM=os + - OPENSLIDES_AUTH_CLIENT_ID=os-ui + - OPENSLIDES_TOKEN_ISSUER=http://keycloak:8080/idp/auth/realms/os + - OPENSLIDES_KEYCLOAK_ADMIN_USERNAME=admin + - OPENSLIDES_KEYCLOAK_ADMIN_PASSWORD=admin depends_on: - datastore-writer datastore-reader: @@ -60,26 +66,21 @@ services: depends_on: - postgres - redis - auth: - build: - context: "https://github.com/OpenSlides/openslides-auth-service.git#main" - dockerfile: "Dockerfile.dev" - image: openslides-auth-dev - ports: - - "9004:9004" + + keycloak: +# build: +# context: "https://github.com/OpenSlides/openslides-auth-service.git#main/keycloak" + image: openslides-keycloak-dev environment: - - ACTION_HOST=backend - - ACTION_PORT=9002 - - MESSAGE_BUS_HOST=redis - - CACHE_HOST=redis - - DATASTORE_READER_HOST=datastore-reader - - DATASTORE_READER_PORT=9010 - - DATASTORE_WRITER_HOST=datastore-writer - - DATASTORE_WRITER_PORT=9011 - depends_on: - - datastore-reader - - datastore-writer - - redis + - KC_BOOTSTRAP_ADMIN_USERNAME=admin + - KC_BOOTSTRAP_ADMIN_PASSWORD=admin + - JAVA_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" + - KEYCLOAK_HOSTNAME=http://keycloak:8080/idp/ + - KEYCLOAK_HTTP_RELATIVE_PATH=/idp/ + ports: + - 18080:8080 + - 15005:5005 + vote: build: context: "https://github.com/OpenSlides/openslides-vote-service.git#main" @@ -98,7 +99,7 @@ services: depends_on: - datastore-reader - redis - - auth + - keycloak postgres: image: postgres:15 environment: diff --git a/dev/run-tests.sh b/dev/run-tests.sh index 9ed0fe9878..a88268f890 100755 --- a/dev/run-tests.sh +++ b/dev/run-tests.sh @@ -7,7 +7,7 @@ DC="docker compose -f dev/docker-compose.dev.yml" $DC up --build --detach $DC exec -T backend scripts/wait.sh datastore-writer 9011 $DC exec -T backend scripts/wait.sh datastore-reader 9010 -$DC exec -T backend scripts/wait.sh auth 9004 +$DC exec -T backend scripts/wait.sh keycloak 8080 $DC exec -T backend ./entrypoint.sh pytest --cov error=$? $DC down --volumes diff --git a/openslides_backend/action/action.py b/openslides_backend/action/action.py index acf14ea543..97305e948c 100644 --- a/openslides_backend/action/action.py +++ b/openslides_backend/action/action.py @@ -11,7 +11,7 @@ from ..models.fields import BaseRelationField from ..permissions.management_levels import ( CommitteeManagementLevel, - OrganizationManagementLevel, + OrganizationManagementLevel, SystemManagementLevel, ) from ..permissions.permission_helper import has_organization_management_level, has_perm from ..permissions.permissions import Permission @@ -85,7 +85,7 @@ class Action(BaseServiceProvider, metaclass=SchemaProvider): is_singular: bool = False action_type: ActionType = ActionType.PUBLIC - permission: Permission | OrganizationManagementLevel | None = None + permission: Permission | OrganizationManagementLevel | SystemManagementLevel | None = None permission_model: Model | None = None permission_id: str | None = None skip_archived_meeting_check: bool = False @@ -197,8 +197,12 @@ def check_permissions(self, instance: dict[str, Any]) -> None: """ Checks permission by requesting permission service or using internal check. """ + print("check_permissions " + str(self.permission)) if self.permission: - if isinstance(self.permission, OrganizationManagementLevel): + if isinstance(self.permission, SystemManagementLevel): + if self.user_id == -1: + return + elif isinstance(self.permission, OrganizationManagementLevel): if has_organization_management_level( self.datastore, self.user_id, @@ -228,6 +232,7 @@ def check_permissions(self, instance: dict[str, Any]) -> None: def check_for_archived_meeting(self, instance: dict[str, Any]) -> None: """Do not allow changing any data in an archived meeting""" + print("check_for_archived_meeting " + str(self.skip_archived_meeting_check)) if self.skip_archived_meeting_check: return try: diff --git a/openslides_backend/action/action_handler.py b/openslides_backend/action/action_handler.py index 2a56056b70..4135fa178f 100644 --- a/openslides_backend/action/action_handler.py +++ b/openslides_backend/action/action_handler.py @@ -4,6 +4,7 @@ from typing import Any, TypeVar, cast import fastjsonschema +from authlib.jose import JWTClaims from ..shared.exceptions import ( ActionException, @@ -29,6 +30,7 @@ Payload, PayloadElement, ) +from ..http.auth_context import AuthContext T = TypeVar("T") @@ -94,7 +96,7 @@ def get_health_info(cls) -> Iterable[tuple[str, dict[str, Any]]]: def handle_request( self, payload: Payload, - user_id: int, + auth_context: AuthContext, atomic: bool = True, internal: bool = False, ) -> ActionsResponse: @@ -103,7 +105,7 @@ def handle_request( parsing all actions. In the end it sends everything to the event store. """ with make_span(self.env, "handle request"): - self.user_id = user_id + self.user_id = auth_context.user_id self.internal = internal try: @@ -154,7 +156,7 @@ def execute_internal_action(self, action: str, data: dict[str, Any]) -> None: "data": [data], } ], - -1, + AuthContext(-1, "", JWTClaims({}, {})), internal=True, ) diff --git a/openslides_backend/action/action_worker.py b/openslides_backend/action/action_worker.py index 6cd9fb5fac..f9cf03e9ba 100644 --- a/openslides_backend/action/action_worker.py +++ b/openslides_backend/action/action_worker.py @@ -9,7 +9,7 @@ from gunicorn.http.wsgi import Response from gunicorn.workers.gthread import ThreadWorker -from openslides_backend.shared.patterns import fqid_from_collection_and_id +from ..shared.patterns import fqid_from_collection_and_id from ..services.datastore.interface import DatastoreService from ..shared.exceptions import ActionException, DatastoreException @@ -18,7 +18,8 @@ from ..shared.interfaces.write_request import WriteRequest from .action_handler import ActionHandler from .util.typing import ActionsResponse, Payload - +from ..http.token_storage import token_storage, TokenStorageUpdate +from ..http.auth_context import AuthContext class ActionWorkerState(str, Enum): RUNNING = "running" @@ -44,7 +45,7 @@ def handle_action_in_worker_thread( ) action_worker_thread = ActionWorker( payload, - user_id, + AuthContext(user_id, token_storage.access_token, token_storage.claims), is_atomic, handler, lock, @@ -221,7 +222,7 @@ class ActionWorker(threading.Thread): def __init__( self, payload: Payload, - user_id: int, + auth_context: AuthContext, is_atomic: bool, handler: ActionHandler, lock: threading.Lock, @@ -230,7 +231,7 @@ def __init__( super().__init__(name="action_worker") self.handler = handler self.payload = payload - self.user_id = user_id + self.auth_context = auth_context self.is_atomic = is_atomic self.lock = lock self.internal = internal @@ -239,12 +240,18 @@ def __init__( def run(self): # type: ignore with self.lock: self.started = True + # set global werkzeug context + token_storage.update(TokenStorageUpdate(access_token= self.auth_context.access_token, + claims= self.auth_context.claims)) try: self.response = self.handler.handle_request( - self.payload, self.user_id, self.is_atomic, self.internal - ) + self.payload, self.auth_context, self.is_atomic, self.internal + ) except Exception as exception: self.exception = exception + finally: + token_storage.clear() + class OSGunicornThread(threading.Thread): diff --git a/openslides_backend/action/actions/user/__init__.py b/openslides_backend/action/actions/user/__init__.py index 19443902c3..46922f03df 100644 --- a/openslides_backend/action/actions/user/__init__.py +++ b/openslides_backend/action/actions/user/__init__.py @@ -19,4 +19,5 @@ toggle_presence_by_number, update, update_self, + backchannel_login ) diff --git a/openslides_backend/action/actions/user/backchannel_login.py b/openslides_backend/action/actions/user/backchannel_login.py new file mode 100644 index 0000000000..74c4199e1f --- /dev/null +++ b/openslides_backend/action/actions/user/backchannel_login.py @@ -0,0 +1,53 @@ +import time +from typing import Any, Iterable + +from datastore.shared.util import FilterOperator +from openslides_backend.permissions.permissions import Permissions +from ...action import Action +from ...util.register import register_action +from ...util.typing import ActionResultElement +from ....models.models import User +from ....permissions.management_levels import SystemManagementLevel +from ....shared.exceptions import ActionException +from ....shared.interfaces.event import Event +from ....shared.patterns import fqid_from_collection_and_id +from ....shared.schema import schema_version + + +@register_action("user.backchannel_login") +class UserBackchannelLogin(Action): + """ + Action to login a user via back-channel. + """ + + model = User() + # must contain an object with a string attribute "idp_id" + schema = { + "$schema": schema_version, + "title": "User login hook schema", + "type": "object", + "properties": { + "os_uid": {"type": "integer"} + }, + "required": ["os_uid"], + "additionalProperties": False + } + + permission = SystemManagementLevel(Permissions.System.CAN_LOGIN) + history_information = "User back-channel login" + skip_archived_meeting_check = True + + def create_action_result_element(self, instance: dict[str, Any]) -> ActionResultElement | None: + return instance + + def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: + os_uid = instance.get("os_uid") + user = self.datastore.get(fqid_from_collection_and_id(self.model.collection, os_uid), ["id"]) + if not user: + raise ActionException(f"User with id {os_uid} not found.") + instance["last_login"] = int(time.time()) + instance["id"] = user["id"] + return instance + + def create_events(self, instance: dict[str, Any]) -> Iterable[Event]: + return [] diff --git a/openslides_backend/action/actions/user/forget_password_confirm.py b/openslides_backend/action/actions/user/forget_password_confirm.py index d3badc8371..766b3f62aa 100644 --- a/openslides_backend/action/actions/user/forget_password_confirm.py +++ b/openslides_backend/action/actions/user/forget_password_confirm.py @@ -2,7 +2,7 @@ from typing import Any from urllib.parse import unquote -from authlib.exceptions import InvalidCredentialsException +from os_authlib.exceptions import InvalidCredentialsException from openslides_backend.action.util.typing import ActionData diff --git a/openslides_backend/http/auth_context.py b/openslides_backend/http/auth_context.py new file mode 100644 index 0000000000..6e7099feca --- /dev/null +++ b/openslides_backend/http/auth_context.py @@ -0,0 +1,11 @@ +from authlib.jose import JWTClaims + +class AuthContext: + user_id: int + access_token: str + claims: JWTClaims + + def __init__(self, user_id: int, access_token: str, claims: JWTClaims): + self.user_id = user_id + self.access_token = access_token + self.claims = claims \ No newline at end of file diff --git a/openslides_backend/http/token_storage.py b/openslides_backend/http/token_storage.py new file mode 100644 index 0000000000..1dd17edbf0 --- /dev/null +++ b/openslides_backend/http/token_storage.py @@ -0,0 +1,24 @@ +from authlib.jose import JWTClaims +from werkzeug.local import Local +from typing import Optional, TypedDict + + +class TokenStorageUpdate(TypedDict, total=False): + access_token: Optional[str] + claims: Optional[JWTClaims] + +class TokenStorage(Local): + access_token: Optional[str] + claims: Optional[JWTClaims] + + def update(self, data: TokenStorageUpdate) -> None: + if 'access_token' in data: + self.access_token = data['access_token'] + if 'claims' in data: + self.claims = data['claims'] + + def clear(self) -> None: + self.access_token = None + self.claims = None + +token_storage = TokenStorage() \ No newline at end of file diff --git a/openslides_backend/http/views/action_view.py b/openslides_backend/http/views/action_view.py index ac9de5955b..88436b41fd 100644 --- a/openslides_backend/http/views/action_view.py +++ b/openslides_backend/http/views/action_view.py @@ -1,7 +1,19 @@ import binascii +import json from base64 import b64decode from pathlib import Path +from smtplib import SMTPAuthenticationError, SMTPSenderRefused +from ssl import SSLCertVerificationError +from authlib.jose import JWTClaims + +from os_authlib.message_bus import MessageBus + +from openslides_backend.action.actions.user.send_invitation_email import EmailErrorType +from openslides_backend.action.mixins.send_email_mixin import EmailUtils, EmailSettings +from openslides_backend.shared.filters import FilterOperator +from openslides_backend.shared.util import fqid_from_collection_and_id +from datastore.shared.util.key_types import id_regex from ...action.action_handler import ActionHandler from ...action.action_worker import handle_action_in_worker_thread from ...i18n.translator import Translator @@ -14,10 +26,10 @@ from ..http_exceptions import Unauthorized from ..request import Request from .base_view import BaseView, route +from .auth import token_required INTERNAL_AUTHORIZATION_HEADER = "Authorization" - VERSION_PATH = Path(__file__).parent / ".." / ".." / "version.txt" @@ -27,16 +39,19 @@ class ActionView(BaseView): ActionHandler after retrieving request user id. """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.message_bus = MessageBus() + @route(["handle_request", "handle_separately"]) - def action_route(self, request: Request) -> RouteResponse: + @token_required + def action_route(self, request: Request, claims: JWTClaims) -> RouteResponse: self.logger.debug("Start dispatching action request.") assert_migration_index() # Get user id. - user_id, access_token = self.get_user_id_from_headers( - request.headers, request.cookies - ) + user_id, access_token = self.get_uid(claims), claims.get("access_token") # Set Headers and Cookies in services. self.services.vote().set_authentication( request.headers.get(AUTHENTICATION_HEADER, ""), @@ -52,6 +67,11 @@ def action_route(self, request: Request) -> RouteResponse: ) return response, access_token + def get_uid(self, claims): + if not claims.get("os_uid"): + return -1 + return int(claims.get("os_uid")) + @route("handle_request", internal=True) def internal_action_route(self, request: Request) -> RouteResponse: self.logger.debug("Start dispatching internal action request.") @@ -84,6 +104,30 @@ def health_route(self, request: Request) -> RouteResponse: def info_route(self, request: Request) -> RouteResponse: return {"healthinfo": {"actions": dict(ActionHandler.get_health_info())}}, None + @route("logout", method="POST", json=False) + def backchannel_logout(self, request: Request) -> RouteResponse: + self.logger.debug("Received logout request") + try: + logout_token = request.form.get("logout_token") + if not logout_token: + self.logger.error("Missing logout_token") + raise ServerError("Missing logout_token") + + decoded_token = self.services.authentication().auth_handler.verify_logout_token(logout_token) + if decoded_token is None: + return AuthenticationException("Invalid logout token") + + session_id = decoded_token.get("sid") + if not session_id: + return AuthenticationException("Missing session ID (sid) in logout token") + + self.logger.debug(f"Session ID to terminate: {session_id}") + self.message_bus.redis.xadd("logout", {"sessionId": session_id}) + + return { "success": True }, None + except json.JSONDecodeError: + return ServerError("Invalid JSON payload", status=400) + @route("version", method="GET", json=False) def version_route(self, _: Request) -> RouteResponse: with open(VERSION_PATH) as file: diff --git a/openslides_backend/http/views/auth.py b/openslides_backend/http/views/auth.py new file mode 100644 index 0000000000..d37da9c7a8 --- /dev/null +++ b/openslides_backend/http/views/auth.py @@ -0,0 +1,35 @@ +import threading + +import requests +from authlib.jose import JsonWebKey +from authlib.oauth2.rfc9068 import JWTBearerTokenValidator +from authlib.oidc.discovery import OpenIDProviderMetadata, get_well_known_url +from werkzeug.exceptions import Unauthorized, Forbidden + +from os_authlib.token_validator import JWTBearerOpenSlidesTokenValidator, create_openslides_token_validator +from ..token_storage import token_storage, TokenStorageUpdate + +KEYCLOAK_DOMAIN = 'http://keycloak:8080/idp' +KEYCLOAK_REALM = 'os' +ISSUER = f"{KEYCLOAK_DOMAIN}/realms/{KEYCLOAK_REALM}" +CERTS_URI = f"{KEYCLOAK_DOMAIN}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/certs" + +def token_required(f): + def decorated_function(view, request, *args, **kwargs): + auth_header = request.headers.get('Authentication') or request.headers.get('Authorization') + if not auth_header: + raise Unauthorized('missing token') + + token = auth_header.split(" ")[1] + validator = create_openslides_token_validator() + claims = validator.authenticate_token(token) + + if not claims: + raise Forbidden('missing or invalid token') + + view.logger.debug(f"Saving Token claims to thread: {threading.get_ident()}") + + token_storage.update(TokenStorageUpdate(access_token=token, claims=claims)) + + return f(view, request, claims, *args, **kwargs) + return decorated_function diff --git a/openslides_backend/http/views/base_view.py b/openslides_backend/http/views/base_view.py index 9948d1656b..cfca9ff2dc 100644 --- a/openslides_backend/http/views/base_view.py +++ b/openslides_backend/http/views/base_view.py @@ -4,7 +4,7 @@ from re import Pattern from typing import Any, Optional -from authlib import AUTHENTICATION_HEADER, COOKIE_NAME +from os_authlib import AUTHENTICATION_HEADER, COOKIE_NAME from werkzeug.exceptions import BadRequest as WerkzeugBadRequest from ...shared.exceptions import View400Exception @@ -109,14 +109,18 @@ def dispatch(self, request: Request) -> RouteResponse: if route_options["json"]: # Check mimetype and parse JSON body. The result is cached in request.json if not request.is_json: + self.logger.debug(f"Wrong media type {request.content_type}. Use 'Content-Type: application/json' instead.") raise View400Exception( "Wrong media type. Use 'Content-Type: application/json' instead." ) try: + self.logger.debug(f"Unpacking JSON.") request_body = request.get_json() except WerkzeugBadRequest as exception: + self.logger.debug(f"Request contains invalid JSON.") raise View400Exception(exception.description) self.logger.debug(f"Request contains JSON: {request_body}.") + self.logger.debug(f"Executing handler.") return func(request) raise NotFound() diff --git a/openslides_backend/migrations/migration_wrapper.py b/openslides_backend/migrations/migration_wrapper.py index 5ddc524623..bc96190c9e 100644 --- a/openslides_backend/migrations/migration_wrapper.py +++ b/openslides_backend/migrations/migration_wrapper.py @@ -11,6 +11,8 @@ setup, ) from datastore.shared.typing import Fqid, Model +from openslides_backend.services.keycloak.adapter import KeycloakAdminAdapter, MigrationKeycloakAdminAdapter +from openslides_backend.services.keycloak.interface import IdpAdminService class BadMigrationModule(MigrationException): @@ -31,7 +33,10 @@ def __init__( print_fn: PrintFunction = print, memory_only: bool = False, ) -> None: + from datastore.shared.di import injector + migrations = MigrationWrapper.load_migrations() + injector.register(IdpAdminService, MigrationKeycloakAdminAdapter) self.handler = setup(verbose, print_fn, memory_only) self.handler.register_migrations(*migrations) diff --git a/openslides_backend/migrations/migrations/0063_user_keycloak_upload.py b/openslides_backend/migrations/migrations/0063_user_keycloak_upload.py new file mode 100644 index 0000000000..8e446353f2 --- /dev/null +++ b/openslides_backend/migrations/migrations/0063_user_keycloak_upload.py @@ -0,0 +1,36 @@ +from openslides_backend.services.keycloak.adapter import MigrationKeycloakAdminAdapter + +from datastore.migrations import BaseModelMigration +from datastore.shared.di import service_as_singleton +from datastore.shared.util import fqid_from_collection_and_id +from datastore.writer.core import BaseRequestEvent, RequestUpdateEvent +from openslides_backend.services.keycloak.interface import IdpAdminService + + +class Migration(BaseModelMigration): + """ + This migration removes all default_number fields from user models + """ + + target_migration_index = 64 + + def __init__(self) -> None: + self.idpAdmin = MigrationKeycloakAdminAdapter() + + def migrate_models(self) -> list[BaseRequestEvent] | None: + events: list[BaseRequestEvent] = [] + db_models = self.reader.get_all("user") + for id_, model in db_models.items(): + if not "kc_id" in model and model.get('username') != 'admin': + print(f"Creating user {model.get('username')} in keycloak...") + # idp_id = self.idpAdmin.create_user(model.get("username"), model.get("password"), model.get("saml_id")) + # events.append( + # RequestUpdateEvent( + # fqid_from_collection_and_id("user", id_), + # { + # "idp_id": idp_id, + # "saml_id": None + # }, + # ) + # ) + return events diff --git a/openslides_backend/permissions/management_levels.py b/openslides_backend/permissions/management_levels.py index 69f299fb46..6a3972e13d 100644 --- a/openslides_backend/permissions/management_levels.py +++ b/openslides_backend/permissions/management_levels.py @@ -58,3 +58,8 @@ class CommitteeManagementLevel(CompareRightLevel): def get_base_model(self) -> str: return "committee" + +class SystemManagementLevel: + + def __init__(self, permission: str): + self.permission = permission \ No newline at end of file diff --git a/openslides_backend/permissions/permissions.py b/openslides_backend/permissions/permissions.py index c5dc6def3c..8cf9df9798 100644 --- a/openslides_backend/permissions/permissions.py +++ b/openslides_backend/permissions/permissions.py @@ -79,6 +79,9 @@ class _User(str, Permission, Enum): CAN_SEE_SENSITIVE_DATA = "user.can_see_sensitive_data" CAN_UPDATE = "user.can_update" +class _System(str, Permission, Enum): + CAN_LOGIN = "system.can_login" + CAN_LOGOUT = "system.can_logout" class Permissions: AgendaItem = _AgendaItem @@ -92,7 +95,7 @@ class Permissions: Projector = _Projector Tag = _Tag User = _User - + System = _System # Holds the corresponding parent for each permission. permission_parents: dict[Permission, list[Permission]] = { diff --git a/openslides_backend/presenter/presenter.py b/openslides_backend/presenter/presenter.py index bb4bd2864a..8d6ff04d87 100644 --- a/openslides_backend/presenter/presenter.py +++ b/openslides_backend/presenter/presenter.py @@ -1,7 +1,7 @@ from collections.abc import Callable import fastjsonschema -from authlib import AUTHENTICATION_HEADER, COOKIE_NAME +from os_authlib import AUTHENTICATION_HEADER, COOKIE_NAME from fastjsonschema import JsonSchemaException from ..http.request import Request diff --git a/openslides_backend/services/auth/adapter.py b/openslides_backend/services/auth/adapter.py index 1c3f231380..946990634c 100644 --- a/openslides_backend/services/auth/adapter.py +++ b/openslides_backend/services/auth/adapter.py @@ -1,6 +1,6 @@ from urllib import parse -from authlib import ( +from os_authlib import ( ANONYMOUS_USER, AUTHORIZATION_HEADER, AuthenticateException, @@ -8,11 +8,10 @@ AuthorizationException, InvalidCredentialsException, ) - +from .interface import AuthenticationService +from ..shared.authenticated_service import AuthenticatedService from ...shared.exceptions import AuthenticationException from ...shared.interfaces.logging import LoggingModule -from ..shared.authenticated_service import AuthenticatedService -from .interface import AuthenticationService class AuthenticationHTTPAdapter(AuthenticationService, AuthenticatedService): @@ -34,9 +33,7 @@ def authenticate(self) -> tuple[int, str | None]: f"Start request to authentication service with the following data: access_token: {self.access_token}, cookie: {self.refresh_id}" ) try: - return self.auth_handler.authenticate( - self.access_token, parse.unquote(self.refresh_id) - ) + return self.auth_handler.authenticate(self.access_token) except (AuthenticateException, InvalidCredentialsException) as e: self.logger.debug(f"Error in auth service: {e.message}") raise AuthenticationException(e.message) @@ -50,13 +47,6 @@ def is_equal(self, toHash: str, toCompare: str) -> bool: def is_anonymous(self, user_id: int) -> bool: return user_id == ANONYMOUS_USER - def create_authorization_token(self, user_id: int, email: str) -> str: - try: - response = self.auth_handler.create_authorization_token(user_id, email) - except AuthenticateException as e: - raise AuthenticationException(e.message) - return response.headers.get(AUTHORIZATION_HEADER, "") - def verify_authorization_token(self, user_id: int, token: str) -> bool: try: found_user_id, _ = self.auth_handler.verify_authorization_token(token) diff --git a/openslides_backend/services/auth/interface.py b/openslides_backend/services/auth/interface.py index 41fa1cfcb5..190c43a06a 100644 --- a/openslides_backend/services/auth/interface.py +++ b/openslides_backend/services/auth/interface.py @@ -1,6 +1,6 @@ from typing import Any, Protocol -from authlib import AUTHENTICATION_HEADER, COOKIE_NAME # noqa +from os_authlib import AUTHENTICATION_HEADER, COOKIE_NAME # noqa from ..shared.authenticated_service import AuthenticatedServiceInterface diff --git a/openslides_backend/services/keycloak/__init__.py b/openslides_backend/services/keycloak/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openslides_backend/services/keycloak/adapter.py b/openslides_backend/services/keycloak/adapter.py new file mode 100644 index 0000000000..877fe946bf --- /dev/null +++ b/openslides_backend/services/keycloak/adapter.py @@ -0,0 +1,176 @@ +import base64 +import json +from os import environ +import logging + +import requests +from keycloak import KeycloakAdmin + +from datastore.shared.di import service_as_singleton +from .interface import IdpAdminService +from ..shared.authenticated_service import AuthenticatedService +from ...http.token_storage import token_storage +from ...shared.interfaces.logging import LoggingModule + +ARGON2_HASH_START = "$argon2" +SHA512_HASHED_LENGTH = 152 + +class CustomKeycloakAdmin(KeycloakAdmin): + def set_user_password_hash(self, user_id, secret_data, credential_data): + self.connection.realm_name = "os" + print(f"Setting password hash for user {user_id}") + update_url = f"{self.connection.server_url}/admin/realms/{self.connection.realm_name}/users/{user_id}" + headers = { + "Authorization": f"Bearer {self.connection.token['access_token']}", + "Content-Type": "application/json" + } + credentials = { + "type": "password", + "secretData": json.dumps(secret_data), + "credentialData": json.dumps(credential_data), + "temporary": False, + } + payload = {"credentials": [credentials]} + response = requests.put(update_url, json=payload, headers=headers) + if response.status_code == 204: + print("Passwort-Hash erfolgreich gesetzt.") + else: + raise Exception(f"Fehler: {response.status_code}, {response.text}") + +@service_as_singleton +class KeycloakAdminAdapter(IdpAdminService, AuthenticatedService): + """ + Adapter to connect keycloak. + """ + keycloak_admin_obj = None + + def __init__(self) -> None: + keycloak_url = environ.get("OPENSLIDES_KEYCLOAK_URL") + self.url = keycloak_url + self.logger = logging.getLogger(__name__) + + def create_keycloak_admin(self): + access_token = token_storage.access_token + keycloak_realm_name = environ.get("OPENSLIDES_AUTH_REALM") + print(f"Creating Keycloak admin with realm: {keycloak_realm_name} on {self.url}") + return CustomKeycloakAdmin(server_url=self.url, token=access_token, realm_name=keycloak_realm_name) + + def keycloak_admin(self): + if self.keycloak_admin_obj is None: + self.keycloak_admin_obj = self.create_keycloak_admin() + return self.keycloak_admin_obj + + def create_user(self, username: str, password_hash: str, saml_id: str | None) -> str: + ''' + public static final String TYPE_KEY = "type"; + public static final String VERSION_KEY = "version"; + public static final String HASH_LENGTH_KEY = "hashLength"; + public static final String MEMORY_KEY = "memory"; + public static final String ITERATIONS_KEY = "iterations"; + public static final String PARALLELISM_KEY = "parallelism"; + + Defaults in the argon2 npm package: + type: argon2id + version: 0x13 + hashLength: 32 + memory: 65536 + parallelism: 4 + iterations: 3 + ''' + if not self.is_sha512_hash(password_hash) and not self.is_argon2_hash(password_hash): + raise ValueError("The password hash is not a valid hash.") + if self.is_sha512_hash(password_hash): + secret_data = { + "value": password_hash + } + credential_data = { + "algorithm": "sha512" + } + else: + # example value: $argon2id$v=19$m=65536,t=3,p=4$ag1cK0W8DxJ6VnUlOdgRKQ$wi/8MnuLaOWZVhO/7p4N+XWgnh6S2qTnrDylY+Z/tQc + hash_data = self.parse_argon2_hash(password_hash) + secret_data = { + "salt": password_hash.split("$")[4], + "value": password_hash.split("$")[5] + } + parameters = password_hash.split("$")[3].split(",") + credential_data = { + "algorithm": "argon2", + "additionalParameters": { + "type": hash_data["argon_type"], + # encode version as hexadecimal + "version": f"0x{hash_data['version']:02x}", + "hashLength": hash_data["hash_length"], + "memory": hash_data["parameters"]["m"], + "parallelism": hash_data["parameters"]["p"], + "iterations": hash_data["parameters"]["t"] + } + + } + print(f"Creating user {username} with password hash {password_hash}") + keycloak_admin = self.keycloak_admin() + existing_user_id = keycloak_admin.get_user_id(username) + user_id = existing_user_id if existing_user_id else keycloak_admin.create_user({"username": username}) + keycloak_admin.set_user_password_hash(user_id, secret_data, credential_data) + if saml_id: + print(f"Setting saml_id {saml_id} for user {user_id}") + keycloak_admin.update_user({"id": user_id, "attributes": {"saml_id": saml_id}}) + return user_id + + def parse_argon2_hash(argon2_hash): + # Split the hash string into its components + parts = argon2_hash.split('$') + if len(parts) != 6: + raise ValueError("Invalid Argon2 hash format.") + + # Extract the components + argon_type = parts[1] # e.g., "argon2id" + version = parts[2][2:] # e.g., "19" (remove "v=") + parameters = parts[3] # e.g., "m=65536,t=3,p=4" + salt_base64 = parts[4] # Base64 encoded salt + hash_base64 = parts[5] # Base64 encoded hash + + # Parse parameters into a dictionary + param_dict = {} + for param in parameters.split(','): + key, value = param.split('=') + param_dict[key] = int(value) + + # Decode the salt and hash + salt = base64.b64decode(salt_base64 + "=" * ((4 - len(salt_base64) % 4) % 4)) + derived_hash = base64.b64decode(hash_base64 + "=" * ((4 - len(hash_base64) % 4) % 4)) + + # Return parsed components + return { + "argon_type": argon_type, + "version": int(version), + "parameters": param_dict, + "salt": salt_base64, + "salt_length": len(salt), + "hash": hash_base64, + "hash_length": len(derived_hash) + } + + + def is_sha512_hash(self, hash: str) -> bool: + return ( + not hash.startswith(ARGON2_HASH_START) and len(hash) == SHA512_HASHED_LENGTH + ) + + def is_argon2_hash(self, hash: str) -> bool: + return hash.startswith(ARGON2_HASH_START) + +@service_as_singleton +class MigrationKeycloakAdminAdapter(KeycloakAdminAdapter): + """ + Adapter to connect keycloak getting admin credentials from environment variables. + """ + def create_keycloak_admin(self): + keycloak_realm_name = environ.get("OPENSLIDES_AUTH_REALM") + keycloak_admin_username = environ.get("OPENSLIDES_KEYCLOAK_ADMIN_USERNAME") + keycloak_admin_password = environ.get("OPENSLIDES_KEYCLOAK_ADMIN_PASSWORD") + print(f"Creating Keycloak admin with realm: {keycloak_realm_name}, username: {keycloak_admin_username} on {self.url}, password: {keycloak_admin_password}") + admin = CustomKeycloakAdmin(server_url="http://keycloak:8080/idp/", username="admin", + password="admin", realm_name="master", client_id="admin-cli", verify=False) + # admin.connection.realm_name = keycloak_realm_name + return admin diff --git a/openslides_backend/services/keycloak/interface.py b/openslides_backend/services/keycloak/interface.py new file mode 100644 index 0000000000..7ce8408a86 --- /dev/null +++ b/openslides_backend/services/keycloak/interface.py @@ -0,0 +1,17 @@ +from abc import abstractmethod +from typing import Any, Protocol + +from datastore.shared.di import service_interface +from ..shared.authenticated_service import AuthenticatedServiceInterface +from ...models.models import User + + +@service_interface +class IdpAdminService(AuthenticatedServiceInterface, Protocol): + """ + Interface of a idp admin service. + """ + + @abstractmethod + def create_user(self, username: str, password_hash: str, saml_id: str | None) -> str: + """Create user and return new IDP user ID""" diff --git a/openslides_backend/services/media/adapter.py b/openslides_backend/services/media/adapter.py index c46216038c..db902a3fa5 100644 --- a/openslides_backend/services/media/adapter.py +++ b/openslides_backend/services/media/adapter.py @@ -1,11 +1,13 @@ +import threading from typing import Any import requests +from os_authlib import AUTHORIZATION_HEADER, AUTHENTICATION_HEADER from ...shared.exceptions import MediaServiceException from ...shared.interfaces.logging import LoggingModule from .interface import MediaService - +from ...http.views.auth import token_storage class MediaServiceAdapter(MediaService): """ @@ -41,7 +43,8 @@ def _handle_upload( self, url: str, payload: dict[str, Any], description: str ) -> None: try: - response = requests.post(url, json=payload) + self.logger.debug(f"Getting access token from : {threading.get_ident()} -> {token_storage.access_token}") + response = requests.post(url, json=payload, headers={'Authorization': f'Bearer {token_storage.access_token}'}) except requests.exceptions.ConnectionError as e: msg = f"Connect to mediaservice failed. {e}" self.logger.debug(description + msg) diff --git a/openslides_backend/services/media/interface.py b/openslides_backend/services/media/interface.py index 99a8226c3b..9a0b421ea0 100644 --- a/openslides_backend/services/media/interface.py +++ b/openslides_backend/services/media/interface.py @@ -15,14 +15,6 @@ def upload_mediafile(self, file: str, id: int, mimetype: str) -> None: """ ... - @abstractmethod - def upload_resource(self, file: str, id: int, mimetype: str) -> None: - """ - Throws a MediaServiceException, if there is a ConnectionError or - any Error reported from MediaService-Request - """ - ... - @abstractmethod def duplicate_mediafile(self, source_id: int, target_id: int) -> None: """ diff --git a/openslides_backend/services/shared/authenticated_service.py b/openslides_backend/services/shared/authenticated_service.py index 7534f689a4..3d225e001b 100644 --- a/openslides_backend/services/shared/authenticated_service.py +++ b/openslides_backend/services/shared/authenticated_service.py @@ -1,7 +1,7 @@ from abc import abstractmethod from typing import Protocol -from authlib import AUTHENTICATION_HEADER, COOKIE_NAME +from os_authlib import AUTHENTICATION_HEADER, COOKIE_NAME class AuthenticatedServiceInterface(Protocol): diff --git a/openslides_backend/shared/base_service_provider.py b/openslides_backend/shared/base_service_provider.py index d3564192b2..bf4b398304 100644 --- a/openslides_backend/shared/base_service_provider.py +++ b/openslides_backend/shared/base_service_provider.py @@ -1,5 +1,6 @@ from openslides_backend.services.auth.interface import AuthenticationService from openslides_backend.services.datastore.interface import DatastoreService +from openslides_backend.services.keycloak.interface import IdpAdminService from openslides_backend.services.media.interface import MediaService from openslides_backend.services.vote.interface import VoteService from openslides_backend.shared.interfaces.logging import Logger, LoggingModule @@ -16,6 +17,7 @@ class BaseServiceProvider: auth: AuthenticationService media: MediaService vote: VoteService + idp_admin: IdpAdminService logging: LoggingModule logger: Logger @@ -32,5 +34,6 @@ def __init__( self.auth = services.authentication() self.media = services.media() self.vote_service = services.vote() + self.idp_admin = services.idp_admin() self.datastore = datastore self.logging = logging diff --git a/openslides_backend/shared/interfaces/services.py b/openslides_backend/shared/interfaces/services.py index 75580e4e87..71d6178c06 100644 --- a/openslides_backend/shared/interfaces/services.py +++ b/openslides_backend/shared/interfaces/services.py @@ -2,6 +2,7 @@ from ...services.auth.interface import AuthenticationService from ...services.datastore.interface import DatastoreService +from ...services.keycloak.interface import IdpAdminService from ...services.media.interface import MediaService from ...services.vote.interface import VoteService @@ -18,3 +19,5 @@ def datastore(self) -> DatastoreService: ... def media(self) -> MediaService: ... def vote(self) -> VoteService: ... + + def idp_admin(self) -> IdpAdminService: ... diff --git a/openslides_backend/test.py b/openslides_backend/test.py new file mode 100644 index 0000000000..5b5516d813 --- /dev/null +++ b/openslides_backend/test.py @@ -0,0 +1,9 @@ +from keycloak import KeycloakAdmin + +# main +if __name__ == "__main__": + KeycloakAdmin(server_url="http://keycloak:8080/idp/", + username="admin", + password="admin", + realm_name="master", + verify=False).get_users() \ No newline at end of file diff --git a/openslides_backend/wsgi.py b/openslides_backend/wsgi.py index 5cdde890b2..f908cab2e0 100644 --- a/openslides_backend/wsgi.py +++ b/openslides_backend/wsgi.py @@ -7,6 +7,7 @@ from .services.auth.adapter import AuthenticationHTTPAdapter from .services.datastore.extended_adapter import ExtendedDatastoreAdapter from .services.datastore.http_engine import HTTPEngine +from .services.keycloak.adapter import KeycloakAdminAdapter from .services.media.adapter import MediaServiceAdapter from .services.vote.adapter import VoteAdapter from .shared.interfaces.logging import LoggingModule @@ -28,6 +29,7 @@ class OpenSlidesBackendServices(containers.DeclarativeContainer): ) datastore = providers.Factory(ExtendedDatastoreAdapter, engine, logging, env) vote = providers.Singleton(VoteAdapter, config.vote_url, logging) + idp_admin = providers.Singleton(KeycloakAdminAdapter) class OpenSlidesBackendWSGI(containers.DeclarativeContainer): diff --git a/requirements/partial/requirements_packaged_services.txt b/requirements/partial/requirements_packaged_services.txt index f91652ad62..676825fe60 100644 --- a/requirements/partial/requirements_packaged_services.txt +++ b/requirements/partial/requirements_packaged_services.txt @@ -1,2 +1,2 @@ git+https://github.com/OpenSlides/openslides-datastore-service.git@${DATASTORE_COMMIT_HASH} -git+https://github.com/OpenSlides/openslides-auth-service.git@${AUTH_COMMIT_HASH}#egg=authlib&subdirectory=auth/libraries/pip-auth +git+https://github.com/kryptance/openslides-auth-service.git@${AUTH_COMMIT_HASH}#egg=os_authlib&subdirectory=libraries/pip-auth diff --git a/requirements/partial/requirements_production.txt b/requirements/partial/requirements_production.txt index aa05940bda..064f61cfb4 100644 --- a/requirements/partial/requirements_production.txt +++ b/requirements/partial/requirements_production.txt @@ -11,6 +11,8 @@ simplejson==3.19.3 Werkzeug==3.1.3 python-magic==0.4.27 pygments==2.19.1 +authlib==1.3.1 +python-keycloak==5.1.1 # opentelemetry opentelemetry-api==1.29.0 diff --git a/requirements/requirements_development_fullstack.txt b/requirements/requirements_development_fullstack.txt new file mode 100644 index 0000000000..b5f3cd3bd8 --- /dev/null +++ b/requirements/requirements_development_fullstack.txt @@ -0,0 +1,4 @@ +-r partial/requirements_production.txt +-r partial/requirements_development.txt +-e /pip-auth +-e /openslides-datastore-service diff --git a/requirements/requirements_development_local.txt b/requirements/requirements_development_local.txt index 91422cb7cf..cec435cf49 100644 --- a/requirements/requirements_development_local.txt +++ b/requirements/requirements_development_local.txt @@ -1,5 +1,5 @@ -r https://raw.githubusercontent.com/OpenSlides/openslides-datastore-service/${DATASTORE_COMMIT_HASH}/requirements/requirements-general.txt --r https://raw.githubusercontent.com/OpenSlides/openslides-auth-service/${AUTH_COMMIT_HASH}/auth/libraries/pip-auth/requirements.txt +-r https://raw.githubusercontent.com/OpenSlides/openslides-auth-service/${AUTH_COMMIT_HASH}/libraries/pip-auth/requirements.txt -r partial/requirements_local_services.txt -r partial/requirements_production.txt -r partial/requirements_development.txt diff --git a/requirements/requirements_development_local_fullstack.txt b/requirements/requirements_development_local_fullstack.txt new file mode 100644 index 0000000000..dee79be393 --- /dev/null +++ b/requirements/requirements_development_local_fullstack.txt @@ -0,0 +1,4 @@ +-r partial/requirements_production.txt +-r partial/requirements_development.txt +-e ../openslides-auth-service/libraries/pip-auth +-e ../openslides-datastore-service diff --git a/tests/integration/test_handle_request.py b/tests/integration/test_handle_request.py index 3ed8509756..94b3a1c3fe 100644 --- a/tests/integration/test_handle_request.py +++ b/tests/integration/test_handle_request.py @@ -2,15 +2,18 @@ from unittest.mock import MagicMock import pytest +from authlib.jose import JWTClaims from openslides_backend.action.action import Action from openslides_backend.action.action_handler import ActionHandler from openslides_backend.action.util.register import register_action from openslides_backend.action.util.typing import ActionData, ActionResults, Payload +from openslides_backend.http.auth_context import AuthContext from openslides_backend.shared.exceptions import ActionException from openslides_backend.shared.interfaces.write_request import WriteRequest from openslides_backend.shared.typing import Schema +AUTH_CONTEXT = AuthContext(-1, "") class BaseTestAction(Action): def perform( @@ -72,7 +75,7 @@ def test_success_actions(action_handler: ActionHandler) -> None: {"action": "success_action", "data": [{}, {}]}, {"action": "success_action", "data": [{}, {}]}, ] - response = action_handler.handle_request(payload, 0) + response = action_handler.handle_request(payload, AUTH_CONTEXT) assert response["success"] is True assert response["results"] == [[], []] @@ -82,7 +85,7 @@ def test_success_actions_atomic(action_handler: ActionHandler) -> None: {"action": "success_action", "data": [{}, {}]}, {"action": "success_action", "data": [{}, {}]}, ] - response = action_handler.handle_request(payload, 0, False) + response = action_handler.handle_request(payload, AUTH_CONTEXT, False) assert response["success"] is True assert response["results"] == [[], []] @@ -92,7 +95,7 @@ def test_success_actions_with_result(action_handler: ActionHandler) -> None: {"action": "success_action", "data": [{}, {}]}, {"action": "action_with_result", "data": [{}, {}]}, ] - response = action_handler.handle_request(payload, 0) + response = action_handler.handle_request(payload, AUTH_CONTEXT) assert response["success"] is True assert response["results"] == [[], [{"id": 1}, {"id": 42}]] @@ -102,7 +105,7 @@ def test_success_actions_with_result_atomic(action_handler: ActionHandler) -> No {"action": "success_action", "data": [{}, {}]}, {"action": "action_with_result", "data": [{}, {}]}, ] - response = action_handler.handle_request(payload, 0, False) + response = action_handler.handle_request(payload, AUTH_CONTEXT, False) assert response["success"] is True assert response["results"] == [[], [{"id": 1}, {"id": 42}]] @@ -113,7 +116,7 @@ def test_with_error(action_handler: ActionHandler) -> None: {"action": "error_action", "data": [{}, {}]}, ] with pytest.raises(ActionException) as e: - action_handler.handle_request(payload, 0) + action_handler.handle_request(payload, AUTH_CONTEXT) assert e.value.action_error_index == 1 assert getattr(e.value, "action_data_error_index", None) is None @@ -123,7 +126,7 @@ def test_with_error_atomic(action_handler: ActionHandler) -> None: {"action": "success_action", "data": [{}, {}]}, {"action": "error_action", "data": [{}, {}]}, ] - response = action_handler.handle_request(payload, 0, False) + response = action_handler.handle_request(payload, AUTH_CONTEXT, False) assert response["success"] is True assert response["results"] == [[], {"success": False, "message": ""}] @@ -134,7 +137,7 @@ def test_with_error_with_index(action_handler: ActionHandler) -> None: {"action": "error_action_with_index", "data": [{}, {}]}, ] with pytest.raises(ActionException) as e: - action_handler.handle_request(payload, 0) + action_handler.handle_request(payload, AUTH_CONTEXT) assert e.value.action_error_index == 1 assert e.value.action_data_error_index == 2 @@ -144,7 +147,7 @@ def test_with_error_with_index_atomic(action_handler: ActionHandler) -> None: {"action": "success_action", "data": [{}, {}]}, {"action": "error_action_with_index", "data": [{}, {}]}, ] - response = action_handler.handle_request(payload, 0, False) + response = action_handler.handle_request(payload, AUTH_CONTEXT, False) assert response["success"] is True assert response["results"] == [ [], diff --git a/tests/system/action/test_action_command_format.py b/tests/system/action/test_action_command_format.py index 20770d9d78..19327bb57b 100644 --- a/tests/system/action/test_action_command_format.py +++ b/tests/system/action/test_action_command_format.py @@ -16,7 +16,7 @@ def get_action_handler(self) -> ActionHandler: logger = Mock() config = Mock() handler = ActionHandler(config, self.services, logger) - handler.user_id = 1 + handler.auth_context = 1 handler.internal = False return handler diff --git a/tests/system/base.py b/tests/system/base.py index 72e37d31cd..d9b68727e8 100644 --- a/tests/system/base.py +++ b/tests/system/base.py @@ -16,6 +16,7 @@ from openslides_backend.services.datastore.with_database_context import ( with_database_context, ) +from openslides_backend.services.keycloak.interface import IdpAdminService from openslides_backend.shared.env import Environment from openslides_backend.shared.exceptions import ActionException, DatastoreException from openslides_backend.shared.filters import FilterOperator @@ -46,6 +47,7 @@ class BaseSystemTestCase(TestCase): datastore: DatastoreService vote_service: TestVoteService media: Any # Any is needed because it is mocked and has magic methods + idp_admin: IdpAdminService client: Client anon_client: Client @@ -65,6 +67,7 @@ def setUp(self) -> None: self.vote_service = cast(TestVoteService, self.services.vote()) self.datastore = self.services.datastore() self.datastore.truncate_db() + self.idp_admin = self.services.idp_admin() self.set_thread_watch_timeout(-1) self.created_fqids = set() diff --git a/tests/system/migrations/test_0063_user_keycloak_upload.py b/tests/system/migrations/test_0063_user_keycloak_upload.py new file mode 100644 index 0000000000..d174611bbf --- /dev/null +++ b/tests/system/migrations/test_0063_user_keycloak_upload.py @@ -0,0 +1,41 @@ +def test_migration(write, finalize, assert_model): + write( + { + "type": "create", + "fqid": "user/1", + "fields": { + "id": 1, + "username": "adam_sandler", + }, + }, + ) + write( + { + "type": "create", + "fqid": "user/2", + "fields": { + "id": 2, + "username": "stifflers", + "saml_id": "mom" + }, + }, + ) + + finalize("0063_user_keycloak_upload") + + assert_model( + "user/1", + { + "id": 1, + "username": "adam_sandler", + "idp_id": "adam_sandler" + }, + ) + assert_model( + "user/2", + { + "id": 2, + "username": "stifflers", + "idp_id": "stifflers_mom" + }, + ) \ No newline at end of file diff --git a/tests/system/util.py b/tests/system/util.py index 7d77ca8bbc..b252286c57 100644 --- a/tests/system/util.py +++ b/tests/system/util.py @@ -14,6 +14,7 @@ from openslides_backend.http.views import ActionView, PresenterView from openslides_backend.http.views.base_view import ROUTE_OPTIONS_ATTR, RouteFunction from openslides_backend.services.datastore.adapter import DatastoreAdapter +from openslides_backend.services.keycloak.interface import IdpAdminService from openslides_backend.services.media.interface import MediaService from openslides_backend.services.vote.adapter import VoteAdapter from openslides_backend.services.vote.interface import VoteService @@ -52,6 +53,13 @@ def vote(self, data: dict[str, Any]) -> Response: ) return convert_to_test_response(response) +class TestIdpAdminAdapter(IdpAdminService): + def set_authentication(self, access_token: str, refresh_id: str) -> None: + pass + + def create_user(self, username: str, idp_id: str | None) -> str: + return username + f"_{idp_id}" if idp_id is not None else "" + def create_action_test_application() -> OpenSlidesBackendWSGIApplication: return create_test_application(ActionView) @@ -87,8 +95,8 @@ def create_test_application(view: type[View]) -> OpenSlidesBackendWSGIApplicatio mock_media_service.upload_mediafile = Mock( side_effect=side_effect_for_upload_method ) - mock_media_service.upload_resource = Mock(side_effect=side_effect_for_upload_method) services.media = MagicMock(return_value=mock_media_service) + services.idp_admin = providers.Singleton(TestIdpAdminAdapter) return create_base_test_application(view, services, env) diff --git a/tests/util.py b/tests/util.py index 01f88f4287..ce7d2ce2dc 100644 --- a/tests/util.py +++ b/tests/util.py @@ -2,7 +2,7 @@ from typing import Any, TypedDict, cast import simplejson as json -from authlib import AUTHENTICATION_HEADER, COOKIE_NAME, AuthenticateException +from os_authlib import AUTHENTICATION_HEADER, COOKIE_NAME, AuthenticateException from werkzeug.test import Client as WerkzeugClient from werkzeug.test import TestResponse from werkzeug.wrappers import Response as BaseResponse