diff --git a/hobo/agent/authentic2/apps.py b/hobo/agent/authentic2/apps.py index e093877..361a191 100644 --- a/hobo/agent/authentic2/apps.py +++ b/hobo/agent/authentic2/apps.py @@ -19,11 +19,20 @@ from django.db.models.signals import pre_save, pre_delete, m2m_changed, post_sav from django.conf import settings +class Plugin: + def get_before_urls(self): + from . import urls + return urls.urlpatterns + + class Authentic2AgentConfig(AppConfig): name = 'hobo.agent.authentic2' label = 'authentic2_agent' verbose_name = 'Authentic2 Agent' + def get_a2_plugin(self): + return Plugin() + def ready(self): from . import provisionning diff --git a/hobo/agent/authentic2/provisionning.py b/hobo/agent/authentic2/provisionning.py index dd89ef7..ae22d1b 100644 --- a/hobo/agent/authentic2/provisionning.py +++ b/hobo/agent/authentic2/provisionning.py @@ -424,29 +424,38 @@ class Provisionning(threading.local): def notify_agents(self, data): if getattr(settings, 'HOBO_HTTP_PROVISIONNING', False): - services_by_url = {} - for services in settings.KNOWN_SERVICES.values(): - for service in services.values(): - if service.get('provisionning-url'): - services_by_url[service['saml-sp-metadata-url']] = service - audience = data.get('audience') - rest_audience = [x for x in audience if x in services_by_url] - amqp_audience = audience - for audience in rest_audience: - service = services_by_url[audience] - data['audience'] = [audience] - try: - response = requests.put( - sign_url(service['provisionning-url'] + '?orig=%s' % service['orig'], service['secret']), - json=data) - response.raise_for_status() - except requests.RequestException as e: - logger.error(u'error provisionning to %s (%s)', audience, e) - else: - amqp_audience.remove(audience) - data['audience'] = amqp_audience - if amqp_audience: - logger.info(u'leftover AMQP audience: %s', amqp_audience) + leftover_audience = self.notify_agents_http(data) + if not leftover_audience: + return + logger.info('leftover AMQP audience: %s', leftover_audience) + data['audience'] = leftover_audience if data['audience']: notify_agents(data) + + def get_http_services_by_url(self): + services_by_url = {} + for services in settings.KNOWN_SERVICES.values(): + for service in services.values(): + if service.get('provisionning-url'): + services_by_url[service['saml-sp-metadata-url']] = service + return services_by_url + + def notify_agents_http(self, data): + services_by_url = self.get_http_services_by_url() + audience = data.get('audience') + rest_audience = [x for x in audience if x in services_by_url] + leftover_audience = audience + for audience in rest_audience: + service = services_by_url[audience] + data['audience'] = [audience] + try: + response = requests.put( + sign_url(service['provisionning-url'] + '?orig=%s' % service['orig'], service['secret']), + json=data) + response.raise_for_status() + except requests.RequestException as e: + logger.error(u'error provisionning to %s (%s)', audience, e) + else: + leftover_audience.remove(audience) + return leftover_audience diff --git a/hobo/agent/authentic2/urls.py b/hobo/agent/authentic2/urls.py new file mode 100644 index 0000000..4646f4a --- /dev/null +++ b/hobo/agent/authentic2/urls.py @@ -0,0 +1,23 @@ +# hobo - portal to configure and deploy applications +# Copyright (C) 2015-2021 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 django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^api/provision/$', views.provision_view), +] diff --git a/hobo/agent/authentic2/views.py b/hobo/agent/authentic2/views.py new file mode 100644 index 0000000..6ac2d2f --- /dev/null +++ b/hobo/agent/authentic2/views.py @@ -0,0 +1,106 @@ +# hobo - portal to configure and deploy applications +# Copyright (C) 2015-2021 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 django.conf import settings + +from rest_framework import permissions, serializers, status +from rest_framework.response import Response +from rest_framework.generics import GenericAPIView + +from . import provisionning + + +class ProvisionSerializer(serializers.Serializer): + user_uuid = serializers.CharField(required=False) + role_uuid = serializers.CharField(required=False) + service_type = serializers.CharField(required=False) + service_url = serializers.CharField(required=False) + + def validate(self, data): + if not (data.get('user_uuid') or data.get('role_uuid')): + raise serializers.ValidationError('must provide user_uuid or role_uuid') + if data.get('user_uuid') and data.get('role_uuid'): + raise serializers.ValidationError('cannot provision both user & role') + return data + + +class ProvisionView(GenericAPIView): + permission_classes = (permissions.IsAuthenticated,) + serializer_class = ProvisionSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + return Response({'err': 1, 'errors': serializer.errors}, status.HTTP_400_BAD_REQUEST) + + engine = ApiProvisionningEngine( + service_type=serializer.validated_data.get('service_type'), + service_url=serializer.validated_data.get('service_url'), + ) + + user_uuid = serializer.validated_data.get('user_uuid') + role_uuid = serializer.validated_data.get('role_uuid') + if user_uuid: + try: + user = provisionning.User.objects.get(uuid=user_uuid) + except provisionning.User.DoesNotExist: + return Response({'err': 1, 'err_desc': 'unknown user UUID'}) + engine.notify_users(ous=None, users=[user]) + elif role_uuid: + try: + role = provisionning.Role.objects.get(uuid=role_uuid) + except provisionning.Role.DoesNotExist: + return Response({'err': 1, 'err_desc': 'unknown role UUID'}) + ous = {ou.id: ou for ou in provisionning.OU.objects.all()} + engine.notify_roles(ous=ous, roles=[role]) + + response = { + 'err': 0, + 'leftover_audience': engine.leftover_audience, + 'reached_audience': engine.reached_audience, + } + if engine.leftover_audience: + response['err'] = 1 + return Response(response) + + +provision_view = ProvisionView.as_view() + + +class ApiProvisionningEngine(provisionning.Provisionning): + def __init__(self, service_type=None, service_url=None): + super().__init__() + self.service_type = service_type + self.service_url = service_url + + def get_http_services_by_url(self): + if self.service_type: + services_by_url = {} + for service in settings.KNOWN_SERVICES[self.service_type].values(): + if service.get('provisionning-url'): + services_by_url[service['saml-sp-metadata-url']] = service + else: + services_by_url = super().get_http_services_by_url() + if self.service_url: + services_by_url = {k: v for k, v in services_by_url.items() if self.service_url in v['url']} + return services_by_url + + def notify_agents(self, data): + self.leftover_audience = self.notify_agents_http(data) + # only include filtered services in leftovers + services_by_url = self.get_http_services_by_url() + self.leftover_audience = [x for x in self.leftover_audience if x in services_by_url] + self.reached_audience = [x for x in services_by_url if x not in self.leftover_audience] diff --git a/tests_authentic/conftest.py b/tests_authentic/conftest.py index 099bcec..2da8f7c 100644 --- a/tests_authentic/conftest.py +++ b/tests_authentic/conftest.py @@ -54,8 +54,22 @@ def tenant_factory(transactional_db, tenant_base, settings): 'title': 'Other', 'service-id': 'welco', 'secret_key': 'abcdef', - 'base_url': 'http://other.example.net' + 'url': 'http://other.example.net', + 'base_url': 'http://other.example.net', + 'provisionning-url': 'http://other.example.net/__provision__/', + 'saml-sp-metadata-url': 'http://other.example.net/metadata/', }, + { + 'slug': 'more', + 'title': 'More', + 'service-id': 'wcs', + 'secret_key': 'abcdef', + 'url': 'http://more.example.net', + 'base_url': 'http://more.example.net', + 'provisionning-url': 'http://more.example.net/__provision__/', + 'saml-sp-metadata-url': 'http://more.example.net/metadata/', + }, + ] }, fd) schema_name = name.replace('-', '_').replace('.', '_') diff --git a/tests_authentic/test_provisionning.py b/tests_authentic/test_provisionning.py index 292e1f4..7c0dfc1 100644 --- a/tests_authentic/test_provisionning.py +++ b/tests_authentic/test_provisionning.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- + import json import pytest +import requests import lasso from mock import patch, call, ANY @@ -16,7 +18,9 @@ from authentic2.a2_rbac.models import Role, RoleAttribute from authentic2.a2_rbac.utils import get_default_ou from authentic2.models import Attribute, AttributeValue from django_rbac.utils import get_ou_model + from hobo.agent.authentic2.provisionning import provisionning +from hobo import signature User = get_user_model() @@ -594,3 +598,76 @@ def test_provision_using_http(transactional_db, tenant, settings, caplog): username='coin2', email='coin2@coin.org', interactive=False) assert notify_agents.call_count == 0 assert requests_put.call_count == 2 + + +def test_provisionning_api(transactional_db, app_factory, tenant, settings, caplog): + with tenant_context(tenant): + # create providers so notification messages have an audience. + LibertyProvider.objects.create(ou=get_default_ou(), name='provider', slug='provider', + entity_id='http://other.example.net/metadata/', + protocol_conformance=lasso.PROTOCOL_SAML_2_0) + LibertyProvider.objects.create(ou=get_default_ou(), name='provider2', slug='provider2', + entity_id='http://more.example.net/metadata/', + protocol_conformance=lasso.PROTOCOL_SAML_2_0) + + role = Role.objects.create(name='coin', ou=get_default_ou()) + user = User.objects.create(username='Étienne', + email='etienne.dugenou@example.net', + first_name='Étienne', + last_name='Dugenou', + ou=get_default_ou()) + + app = app_factory(tenant) + resp = app.post_json('/api/provision/', {}, status=403) + + orig = settings.KNOWN_SERVICES['welco']['other']['verif_orig'] + key = settings.KNOWN_SERVICES['welco']['other']['secret'] + resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, key), {}, status=400) + assert resp.json['errors']['__all__'] == ['must provide user_uuid or role_uuid'] + + resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, + key), {'user_uuid': 'xxx', 'role_uuid': 'yyy'}, status=400) + assert resp.json['errors']['__all__'] == ['cannot provision both user & role'] + + resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, + key), {'user_uuid': 'xxx'}, status=200) + assert resp.json == {'err': 1, 'err_desc': 'unknown user UUID'} + + resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, + key), {'role_uuid': 'xxx'}, status=200) + assert resp.json == {'err': 1, 'err_desc': 'unknown role UUID'} + + with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put: + resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, + key), {'user_uuid': user.uuid}) + assert requests_put.call_count == 2 + assert not resp.json['leftover_audience'] + assert set(resp.json['reached_audience']) == { + 'http://other.example.net/metadata/', + 'http://more.example.net/metadata/'} + + with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put: + resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, + key), {'user_uuid': user.uuid, 'service_type': 'welco'}) + assert requests_put.call_count == 1 + assert not resp.json['leftover_audience'] + assert set(resp.json['reached_audience']) == {'http://other.example.net/metadata/'} + + with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put: + resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, + key), {'user_uuid': user.uuid, 'service_url': 'example.net'}) + assert requests_put.call_count == 2 + + with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put: + resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, + key), {'role_uuid': role.uuid}) + assert requests_put.call_count == 2 + assert resp.json['err'] == 0 + assert not resp.json['leftover_audience'] + + with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put: + requests_put.side_effect = requests.RequestException + resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, + key), {'role_uuid': role.uuid}) + assert resp.json['err'] == 1 + assert resp.json['leftover_audience']