mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-09 11:17:55 +02:00
✨(backend) add a service to call the yhub REST API
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user