From 5845ad8a03abf683158e6909d0f52ff4d5e61605 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 5 Aug 2026 15:13:54 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20a=20service=20to=20c?= =?UTF-8?q?all=20the=20yhub=20REST=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend application will have to call the yhub REST API for some operations. We want to use a dedicated service to do that. This first commit introduces the shape of this service, it only does the configuration for now, calling actions will be implemented later. --- CHANGELOG.md | 1 + documentation/env.md | 3 + env.d/development/common | 1 + src/backend/core/services/yhub_services.py | 165 ++++++++++++++++++ src/backend/core/tests/test_api_jwks.py | 2 +- .../tests/test_services_converter_services.py | 2 +- .../core/tests/test_services_jwt_services.py | 2 +- .../core/tests/test_services_yhub_services.py | 134 ++++++++++++++ .../tests/utils/{jwt.py => jwt_helper.py} | 0 src/backend/impress/settings.py | 13 ++ 10 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 src/backend/core/services/yhub_services.py create mode 100644 src/backend/core/tests/test_services_yhub_services.py rename src/backend/core/tests/utils/{jwt.py => jwt_helper.py} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9655cd1b..bf1a42504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ and this project adheres to `503` instead of denying access like a permission failure, so clients retry instead of giving up. The built-in endpoints can also answer JSON on `Accept: application/json` +- ✨(backend) add a service to call the yhub REST API - ✨(backend) add a service generating cached RS256 JWT tokens - ✨(backend) publish the JWT public key on a JWKS endpoint - 🔧(dev) generate the JWT signing key when bootstrapping the dev stack diff --git a/documentation/env.md b/documentation/env.md index d5ef078c9..c02648160 100644 --- a/documentation/env.md +++ b/documentation/env.md @@ -141,6 +141,9 @@ These are the environment variables you can set for the `impress-backend` contai | USER_ONBOARDING_DOCUMENTS | A list of documents IDs for which a read-only access will be created for new s | [] | | USER_ONBOARDING_SANDBOX_DOCUMENT | ID of a template sandbox document that will be duplicated for new users | | | USER_RECONCILIATION_FORM_URL | URL of a third-party form for user reconciliation requests | | +| YHUB_API_BASE_URL | Base url of the yhub collaboration server REST API | | +| YHUB_API_TIMEOUT | Timeout (in seconds) of the requests to the yhub API | 30 | +| YHUB_ORG | yhub organization the documents live in. Must match the YHUB_ORG of the yhub server | docs | | Y_PROVIDER_API_BASE_URL | Y Provider url | | | Y_PROVIDER_API_KEY | Y provider API key | | diff --git a/env.d/development/common b/env.d/development/common index 8b406be28..4b09afab7 100644 --- a/env.d/development/common +++ b/env.d/development/common @@ -83,6 +83,7 @@ COLLABORATION_WS_URL=ws://localhost:3002/collaboration/ws/v1/docs COLLABORATION_WS_INACTIVITY_TIMEOUT=15 # Seconds # server-to-server, reached with an admin JWT (aud: yhub) COLLABORATION_API_URL=http://yhub:3002/collaboration +YHUB_API_BASE_URL=http://yhub:3002 DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token Y_PROVIDER_API_BASE_URL=http://y-provider-development-converter:4444/api/ diff --git a/src/backend/core/services/yhub_services.py b/src/backend/core/services/yhub_services.py new file mode 100644 index 000000000..9cf6a66a4 --- /dev/null +++ b/src/backend/core/services/yhub_services.py @@ -0,0 +1,165 @@ +""" +yhub API services. + +yhub is the collaboration server holding the live Yjs state of the documents +(see `src/yhub-server`). Beside the websocket used by the editors, it exposes a +REST API letting a backend read and act on a document out of band. + +Every route is mounted under the `apiPrefix` yhub is configured with, and a +room is addressed as `/{prefix}/{endpoint}/{version}/{org}/{docid}`, where `org` +is the yhub organization Docs runs under and `docid` the document id. The +built-in endpoints are `ydoc` (get the state of a document, patch it with a Yjs +update), `rollback`, `prune`, `changeset` and `activity`, all at `v1`. yhub also +accepts a `branch` query parameter, but our auth plugin only ever grants access +to the `main` branch, so this service never sends it. + +This service only owns the transport for now, the endpoints are added as we +need them. +""" + +import logging + +from django.conf import settings + +import requests + +from core.services.jwt_services import JWTService + +logger = logging.getLogger(__name__) + + +class YHubError(Exception): + """Base exception for yhub related errors.""" + + +class ConfigurationError(YHubError): + """Raised when the yhub service is not properly configured.""" + + +class ServiceUnavailableError(YHubError): + """Raised when the yhub service cannot be reached.""" + + +class APIError(YHubError): + """Raised when the yhub API answers with an error status.""" + + def __init__(self, message, status_code=None): + super().__init__(message) + self.status_code = status_code + + +class YHubService: + """ + Client for the REST API of the yhub collaboration server. + + It owns the transport: where yhub lives, how a request is authenticated and + how a failure is reported. The endpoints themselves are added as we need + them, on top of `build_url` and `request`. + + A call serving the request of an authenticated user should be made by a + service built with that user, the token then names them as its subject. + """ + + # Segment every yhub route is mounted under. yhub defaults it to "api", we + # serve it under "collaboration" and configure its `apiPrefix` to match. It + # is a single path segment, yhub rejects anything else at startup. + api_prefix = "collaboration" + + # Version of the endpoints we call, the one all the built-ins are at. + api_version = "v1" + + def __init__(self, user=None): + """Bind the service to the user a call is made on behalf of, if any.""" + self.user = user + + @property + def base_url(self): + """Return the base url of the yhub API, without its trailing slash.""" + base_url = settings.YHUB_API_BASE_URL + if not base_url: + raise ConfigurationError( + "The YHUB_API_BASE_URL setting is required to reach the yhub API." + ) + return base_url.rstrip("/") + + @property + def org(self): + """Return the yhub organization the documents live in.""" + return settings.YHUB_ORG + + @property + def timeout(self): + """Return the timeout of the requests to the yhub API, in seconds.""" + return settings.YHUB_API_TIMEOUT + + @property + def claims(self): + """ + Build the claims naming who a request to the yhub API is made for. + + The "sub" claim is only there when the call is made on behalf of an + authenticated user, so that yhub attributes what it changes to them + rather than to the backend itself. A call made outside of a request, + from a Celery task for instance, has no subject to name. + """ + if self.user is None or not self.user.is_authenticated: + return {} + + return {"sub": str(self.user.pk)} + + @property + def auth_header(self): + """ + Build the authentication header of a request to the yhub API. + + The token always grants admin, a server-to-server call acts on a + document without going through the abilities of a user. The subject it + may carry is who the call is for, it never restricts what it can do. + """ + return f"Bearer {JWTService().get_admin_token(self.claims)}" + + def build_url(self, endpoint, document_id): + """Build the url of a document scoped endpoint of the yhub API.""" + return ( + f"{self.base_url}/{self.api_prefix}/{endpoint}/{self.api_version}" + f"/{self.org}/{document_id}" + ) + + def request(self, method, url, params=None, data=None): + """ + Send an authenticated request to the yhub API. + + Return the raw response, it is up to the caller to decode its body: the + endpoints do not all answer with the same payload. + """ + try: + response = requests.request( + method, + url, + params=params, + data=data, + headers={ + "Authorization": self.auth_header, + "Content-Type": "application/octet-stream", + }, + timeout=self.timeout, + ) + except requests.RequestException as err: + logger.exception("yhub service error: url=%s", url) + raise ServiceUnavailableError( + f"Failed to connect to the yhub service at {url}" + ) from err + + if not response.ok: + logger.error( + "yhub API error: url=%s, status=%d, response=%s", + url, + response.status_code, + response.text[:200] if response.text else "empty", + ) + raise APIError( + f"The yhub API answered {response.status_code} on {url}", + status_code=response.status_code, + ) + + return response diff --git a/src/backend/core/tests/test_api_jwks.py b/src/backend/core/tests/test_api_jwks.py index 348d55a6c..1618247dc 100644 --- a/src/backend/core/tests/test_api_jwks.py +++ b/src/backend/core/tests/test_api_jwks.py @@ -9,7 +9,7 @@ import pytest from rest_framework.test import APIClient from core.services.jwt_services import JWTService -from core.tests.utils.jwt import generate_key_pair +from core.tests.utils.jwt_helper import generate_key_pair from core.tests.utils.urls import reload_urls pytestmark = pytest.mark.django_db diff --git a/src/backend/core/tests/test_services_converter_services.py b/src/backend/core/tests/test_services_converter_services.py index 0b6f5183b..949088599 100644 --- a/src/backend/core/tests/test_services_converter_services.py +++ b/src/backend/core/tests/test_services_converter_services.py @@ -13,7 +13,7 @@ from core.services.converter_services import ( ValidationError, YdocConverter, ) -from core.tests.utils.jwt import generate_key_pair +from core.tests.utils.jwt_helper import generate_key_pair # Generating an RSA key is expensive, do it once for the whole module PRIVATE_KEY, PUBLIC_KEY = generate_key_pair() diff --git a/src/backend/core/tests/test_services_jwt_services.py b/src/backend/core/tests/test_services_jwt_services.py index f2bc8f2b2..eaf0f2989 100644 --- a/src/backend/core/tests/test_services_jwt_services.py +++ b/src/backend/core/tests/test_services_jwt_services.py @@ -17,7 +17,7 @@ from core.services.jwt_services import ( JWTService, TokenGenerationError, ) -from core.tests.utils.jwt import generate_key_pair +from core.tests.utils.jwt_helper import generate_key_pair # Generating RSA keys is expensive, do it once for the whole module PRIVATE_KEY, PUBLIC_KEY = generate_key_pair() diff --git a/src/backend/core/tests/test_services_yhub_services.py b/src/backend/core/tests/test_services_yhub_services.py new file mode 100644 index 000000000..1b92569ce --- /dev/null +++ b/src/backend/core/tests/test_services_yhub_services.py @@ -0,0 +1,134 @@ +"""Test yhub services.""" + +from unittest.mock import patch + +from django.contrib.auth.models import AnonymousUser + +import jwt +import pytest +import requests + +from core.factories import UserFactory +from core.services.yhub_services import ( + APIError, + ConfigurationError, + ServiceUnavailableError, + YHubService, +) +from core.tests.utils.jwt_helper import generate_key_pair + +# Generating an RSA key is expensive, do it once for the whole module +PRIVATE_KEY, PUBLIC_KEY = generate_key_pair() + + +@pytest.fixture(autouse=True) +def yhub_settings(settings): + """Setup valid settings for the yhub service and the JWT service it signs with.""" + settings.YHUB_API_BASE_URL = "http://yhub:3002" + settings.YHUB_ORG = "docs" + settings.YHUB_API_TIMEOUT = 30 + settings.JWT_PRIVATE_KEY = PRIVATE_KEY + settings.JWT_TOKEN_LIFETIME = 3600 + + +def test_base_url_required(settings): + """Should raise ConfigurationError when the base url is not configured.""" + settings.YHUB_API_BASE_URL = None + service = YHubService() + + with pytest.raises(ConfigurationError, match="YHUB_API_BASE_URL"): + _ = service.base_url + + +def test_base_url_strips_trailing_slash(settings): + """The trailing slash of the base url should not leak into the urls we build.""" + settings.YHUB_API_BASE_URL = "http://yhub:3002/" + + assert YHubService().base_url == "http://yhub:3002" + + +def test_build_url(): + """A document scoped url should be mounted under the api prefix of yhub.""" + url = YHubService().build_url("ydoc", "8c1c8c4d-4b02-4b0f-a0e9-e00cbd1a9a2f") + + assert url == ( + "http://yhub:3002/collaboration/ydoc/v1/docs/" + "8c1c8c4d-4b02-4b0f-a0e9-e00cbd1a9a2f" + ) + + +def test_auth_header(): + """The auth header should carry an admin JWT signed with the configured key.""" + scheme, token = YHubService().auth_header.split(" ") + + assert scheme == "Bearer" + payload = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"]) + assert payload["admin"] is True + assert "sub" not in payload + + +def test_auth_header_with_user(): + """The token should name the user a call is made on behalf of as its subject.""" + user = UserFactory.build() + + _scheme, token = YHubService(user=user).auth_header.split(" ") + + payload = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"]) + assert payload["sub"] == str(user.pk) + # naming a subject should not restrict what the call can do + assert payload["admin"] is True + + +def test_auth_header_with_anonymous_user(): + """An anonymous user is no subject, the token should not name one.""" + _scheme, token = YHubService(user=AnonymousUser()).auth_header.split(" ") + + payload = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"]) + assert "sub" not in payload + assert payload["admin"] is True + + +@patch("requests.request") +def test_request(mock_request): + """Should send an authenticated request to the yhub API.""" + mock_request.return_value.ok = True + service = YHubService() + + response = service.request( + "get", service.build_url("ydoc", "doc-id"), params={"gc": "false"} + ) + + assert response is mock_request.return_value + args, kwargs = mock_request.call_args + assert args == ("get", "http://yhub:3002/collaboration/ydoc/v1/docs/doc-id") + assert kwargs["params"] == {"gc": "false"} + assert kwargs["timeout"] == 30 + assert kwargs["headers"]["Authorization"].startswith("Bearer ") + + +@patch("requests.request") +def test_request_service_unavailable(mock_request): + """Should raise ServiceUnavailableError when yhub cannot be reached.""" + mock_request.side_effect = requests.RequestException("Connection error") + + with pytest.raises( + ServiceUnavailableError, match="Failed to connect to the yhub service" + ): + YHubService().request( + "get", "http://yhub:3002/collaboration/ydoc/v1/docs/doc-id" + ) + + +@patch("requests.request") +def test_request_error_status(mock_request): + """Should raise APIError, carrying the status, when yhub answers an error.""" + mock_request.return_value.ok = False + mock_request.return_value.status_code = 403 + mock_request.return_value.text = "Forbidden" + + with pytest.raises(APIError, match="The yhub API answered 403") as excinfo: + YHubService().request( + "get", "http://yhub:3002/collaboration/ydoc/v1/docs/doc-id" + ) + + assert excinfo.value.status_code == 403 diff --git a/src/backend/core/tests/utils/jwt.py b/src/backend/core/tests/utils/jwt_helper.py similarity index 100% rename from src/backend/core/tests/utils/jwt.py rename to src/backend/core/tests/utils/jwt_helper.py diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index f3b1be0d6..2dce1f7f6 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -528,6 +528,19 @@ class Base(Configuration): None, environ_name="COLLABORATION_API_URL", environ_prefix=None ) + # yhub collaboration server, as reached by core.services.yhub_services + YHUB_API_BASE_URL = values.Value( + None, environ_name="YHUB_API_BASE_URL", environ_prefix=None + ) + # The yhub organization our documents live in. It must match the YHUB_ORG + # of the yhub server, which rejects the rooms of any other organization. + YHUB_ORG = values.Value("docs", environ_name="YHUB_ORG", environ_prefix=None) + YHUB_API_TIMEOUT = values.IntegerValue( + default=30, + environ_name="YHUB_API_TIMEOUT", + environ_prefix=None, + ) + # JWT # RSA private key (PEM) used to sign the tokens issued by # core.services.jwt_services.JWTService. Prefer the JWT_PRIVATE_KEY_FILE