From f7623c0ee13992cb18eb6d764278db67ec191877 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Wed, 14 Apr 2021 00:13:15 +0200
Subject: [PATCH] authentic: add API to force user provisionning (#53059)

---
 hobo/agent/authentic2/apps.py          |   9 +++
 hobo/agent/authentic2/provisionning.py |  55 +++++++------
 hobo/agent/authentic2/urls.py          |  23 ++++++
 hobo/agent/authentic2/views.py         | 106 +++++++++++++++++++++++++
 tests_authentic/conftest.py            |  16 +++-
 tests_authentic/test_provisionning.py  |  77 ++++++++++++++++++
 6 files changed, 262 insertions(+), 24 deletions(-)
 create mode 100644 hobo/agent/authentic2/urls.py
 create mode 100644 hobo/agent/authentic2/views.py

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 <http://www.gnu.org/licenses/>.
+
+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 <http://www.gnu.org/licenses/>.
+
+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']
-- 
2.31.0

