diff --git a/passerelle/apps/proxy/__init__.py b/passerelle/apps/proxy/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/proxy/migrations/0001_initial.py b/passerelle/apps/proxy/migrations/0001_initial.py
new file mode 100644
index 00000000..15169a63
--- /dev/null
+++ b/passerelle/apps/proxy/migrations/0001_initial.py
@@ -0,0 +1,82 @@
+# Generated by Django 2.2.26 on 2023-01-08 12:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('base', '0030_resourcelog_base_resour_appname_298cbc_idx'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Resource',
+ fields=[
+ (
+ 'id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+ ),
+ ('title', models.CharField(max_length=50, verbose_name='Title')),
+ ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
+ ('description', models.TextField(verbose_name='Description')),
+ (
+ 'basic_auth_username',
+ models.CharField(
+ blank=True, max_length=128, verbose_name='Basic authentication username'
+ ),
+ ),
+ (
+ 'basic_auth_password',
+ models.CharField(
+ blank=True, max_length=128, verbose_name='Basic authentication password'
+ ),
+ ),
+ (
+ 'client_certificate',
+ models.FileField(
+ blank=True, null=True, upload_to='', verbose_name='TLS client certificate'
+ ),
+ ),
+ (
+ 'trusted_certificate_authorities',
+ models.FileField(blank=True, null=True, upload_to='', verbose_name='TLS trusted CAs'),
+ ),
+ (
+ 'verify_cert',
+ models.BooleanField(blank=True, default=True, verbose_name='TLS verify certificates'),
+ ),
+ (
+ 'http_proxy',
+ models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy'),
+ ),
+ ('upstream_base_url', models.URLField(verbose_name='Upstream Service Base URL')),
+ (
+ 'http_timeout',
+ models.PositiveIntegerField(default=20, verbose_name='Timeout on upstream (in seconds)'),
+ ),
+ (
+ 'forced_headers',
+ models.TextField(
+ blank=True,
+ help_text='Headers to always add (one per line, format "Header-Name: value")',
+ verbose_name='Headers',
+ ),
+ ),
+ (
+ 'users',
+ models.ManyToManyField(
+ blank=True,
+ related_name='_resource_users_+',
+ related_query_name='+',
+ to='base.ApiUser',
+ ),
+ ),
+ ],
+ options={
+ 'verbose_name': 'Proxy',
+ },
+ ),
+ ]
diff --git a/passerelle/apps/proxy/migrations/__init__.py b/passerelle/apps/proxy/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/proxy/models.py b/passerelle/apps/proxy/models.py
new file mode 100644
index 00000000..08786f89
--- /dev/null
+++ b/passerelle/apps/proxy/models.py
@@ -0,0 +1,94 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2023 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from urllib.parse import parse_qsl
+
+from django.db import models
+from django.http.response import HttpResponse
+from django.utils.translation import gettext_lazy as _
+
+from passerelle.base.models import BaseResource, HTTPResource
+from passerelle.utils.api import endpoint
+
+PASS_HEADERS_REQUEST = (
+ 'accept',
+ 'accept-encoding',
+ 'accept-language',
+ 'cookie',
+ 'user-agent',
+)
+
+
+class Resource(BaseResource, HTTPResource):
+ category = _('Misc')
+
+ upstream_base_url = models.URLField(_('Upstream Service Base URL'))
+ http_timeout = models.PositiveIntegerField(_('Timeout on upstream (in seconds)'), default=20)
+ forced_headers = models.TextField(
+ _('Headers'),
+ blank=True,
+ help_text=_('Headers to always add (one per line, format "Header-Name: value")'),
+ )
+
+ class Meta:
+ verbose_name = _('Proxy')
+
+ @endpoint(
+ name='request',
+ perm='can_access',
+ methods=['get', 'post', 'delete', 'put', 'patch'],
+ pattern=r'^(?P.*)$',
+ description=_('Make a request'),
+ example_pattern='{path}',
+ parameters={
+ 'path': {
+ 'description': _('request will be made on Upstream Service Base URL + path'),
+ 'example_value': 'foo/bar',
+ }
+ },
+ )
+ def request(self, request, path, *args, **kwargs):
+ params = parse_qsl(request.META.get('QUERY_STRING'))
+ if params and params[-1][0] == 'signature':
+ # remove Publik signature parts: orig, algo, timestamp, nonce, signature
+ params = [
+ (k, v) for k, v in params if k not in ('orig', 'algo', 'timestamp', 'nonce', 'signature')
+ ]
+ headers = {k: v for k, v in request.headers.items() if v and k.lower() in PASS_HEADERS_REQUEST}
+ if request.method != 'GET':
+ headers['Content-Type'] = request.headers.get('content-type')
+ for header in self.forced_headers.splitlines():
+ header = header.strip()
+ if header.startswith('#'):
+ continue
+ header = header.split(':', 1)
+ if len(header) == 2:
+ headers[header[0].strip()] = header[1].strip()
+ upstream = self.requests.request(
+ method=request.method,
+ url=self.upstream_base_url + path,
+ headers=headers,
+ params=params,
+ data=request.body,
+ timeout=self.http_timeout,
+ )
+ response = HttpResponse(
+ upstream.content,
+ content_type=upstream.headers.get('Content-Type'),
+ status=upstream.status_code,
+ reason=upstream.reason,
+ )
+ return response
diff --git a/passerelle/settings.py b/passerelle/settings.py
index 3626bbd6..464c3b0c 100644
--- a/passerelle/settings.py
+++ b/passerelle/settings.py
@@ -168,6 +168,7 @@ INSTALLED_APPS = (
'passerelle.apps.phonecalls',
'passerelle.apps.photon',
'passerelle.apps.plone_restapi',
+ 'passerelle.apps.proxy',
'passerelle.apps.sector',
'passerelle.apps.sfr_dmc',
'passerelle.apps.signal_arretes',
diff --git a/tests/test_proxy.py b/tests/test_proxy.py
new file mode 100644
index 00000000..d4ab3df5
--- /dev/null
+++ b/tests/test_proxy.py
@@ -0,0 +1,169 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2023 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import pytest
+import responses
+import responses.matchers
+
+import tests.utils
+from passerelle.apps.proxy.models import Resource
+
+
+@pytest.fixture
+def proxy(db):
+ return tests.utils.setup_access_rights(
+ Resource.objects.create(slug='echo', upstream_base_url='https://example.org/')
+ )
+
+
+@pytest.fixture
+def endpoint(proxy):
+ return tests.utils.generic_endpoint_url('proxy', 'request', slug=proxy.slug)
+
+
+@pytest.fixture
+def mocked_responses():
+ with responses.RequestsMock(assert_all_requests_are_fired=False) as rsp:
+ yield rsp
+
+
+def test_get(mocked_responses, app, proxy, endpoint):
+ mocked_responses.get(
+ url='https://example.org/foo/bar',
+ body='ok',
+ match=[
+ responses.matchers.query_string_matcher(''),
+ ],
+ )
+
+ resp = app.get(endpoint + '/foo/bar', status=200)
+ assert resp.text == 'ok'
+
+
+def test_status(mocked_responses, app, proxy, endpoint):
+ mocked_responses.get(url='https://example.org/foo/bar', body='bad request', status=400)
+ resp = app.get(endpoint + '/foo/bar', status=400)
+ assert resp.text == 'bad request'
+
+
+def test_post(mocked_responses, app, proxy, endpoint):
+ mocked_responses.post(
+ url='https://example.org/post',
+ body='ok',
+ match=[
+ responses.matchers.json_params_matcher({'foo': 'bar'}),
+ ],
+ )
+ resp = app.post_json(endpoint + '/post', params={'foo': 'bar'}, status=200)
+ assert resp.text == 'ok'
+
+
+def no_header_matcher(header_name):
+ def inner(request):
+ if header_name in request.headers:
+ return False, f'Found header "{header_name} in requests headers.'
+ return True, None
+
+ return inner
+
+
+def test_headers(mocked_responses, app, proxy, endpoint):
+ mocked_responses.get(
+ url='https://example.org/foo/bar',
+ body='ok',
+ match=[
+ responses.matchers.header_matcher({'User-Agent': 'test-ua'}),
+ no_header_matcher('x-foo'),
+ no_header_matcher('dontpass'),
+ ],
+ )
+ resp = app.get(endpoint + '/foo/bar', headers={'user-agent': 'test-ua', 'dontpass': 'x'}, status=200)
+ assert resp.text == 'ok'
+
+
+def test_forced_headers(mocked_responses, app, proxy, endpoint):
+ proxy.forced_headers = '''
+x-foo : bar
+badentry
+
+# comment: do not use me
+ '''
+ proxy.save()
+
+ mocked_responses.get(
+ url='https://example.org/foo/bar',
+ body='ok',
+ match=[
+ responses.matchers.header_matcher({'x-foo': 'bar'}),
+ no_header_matcher('dontpass'),
+ ],
+ )
+ resp = app.get(endpoint + '/foo/bar', headers={'user-agent': 'test', 'dontpass': 'x'}, status=200)
+ assert resp.text == 'ok'
+
+
+def test_query_parameters(mocked_responses, app, proxy, endpoint):
+ mocked_responses.get(
+ url='https://example.org/foo/bar',
+ body='ok',
+ match=[
+ responses.matchers.query_param_matcher({'param1': '1', 'param2': '2'}, strict_match=True),
+ ],
+ )
+
+ resp = app.get(endpoint + '/foo/bar?param1=1¶m2=2', status=200)
+ assert resp.text == 'ok'
+
+
+def test_publik_signature_is_removed(mocked_responses, app, proxy, endpoint):
+ mocked_responses.get(
+ url='https://example.org/foo/bar',
+ body='ok',
+ match=[
+ responses.matchers.query_param_matcher({'param1': '1', 'param2': '2'}, strict_match=True),
+ ],
+ )
+ resp = app.get(
+ endpoint + '/foo/bar?param1=1¶m2=2&orig=coucou&algo=foo&nonce=bar×tamp=xxx&signature=okok',
+ status=200,
+ )
+ assert resp.text == 'ok'
+
+
+def test_auth(mocked_responses, app, proxy, endpoint):
+ proxy.basic_auth_username = 'test-login'
+ proxy.basic_auth_password = 'test-pass'
+ proxy.save()
+
+ mocked_responses.get(
+ url='https://example.org/foo/bar',
+ body='ok',
+ match=[responses.matchers.header_matcher({'Authorization': 'Basic dGVzdC1sb2dpbjp0ZXN0LXBhc3M='})],
+ )
+ resp = app.get(endpoint + '/foo/bar', status=200)
+ assert resp.text == 'ok'
+
+
+def test_timeout(mocked_responses, app, proxy, endpoint):
+ proxy.http_timeout = 5
+ proxy.save()
+ mocked_responses.get(
+ url='https://example.org/foo/bar',
+ body='ok',
+ match=[responses.matchers.request_kwargs_matcher({'timeout': 5})],
+ )
+ resp = app.get(endpoint + '/foo/bar', status=200)
+ assert resp.text == 'ok'