From 46ca698135094e429911dac19c0deb08b634c3f3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Mon, 25 May 2020 21:15:26 +0200
Subject: [PATCH] general: use HTTP API to provision users & groups (#43245)

---
 debian/debian_config_common.py                |   2 +
 hobo/agent/authentic2/provisionning.py        |  39 ++++-
 .../common/management/commands/hobo_notify.py | 141 +---------------
 hobo/environment/models.py                    |   8 +
 hobo/environment/utils.py                     |  44 +++--
 hobo/multitenant/settings_loaders.py          |   2 +
 hobo/provisionning/__init__.py                |   0
 hobo/provisionning/middleware.py              |  81 +++++++++
 hobo/provisionning/utils.py                   | 159 ++++++++++++++++++
 tests/settings.py                             |   6 +-
 tests_authentic/test_provisionning.py         |  58 +++++++
 tests_multitenant/test_settings.py            |   2 +-
 tests_schemas/example_env.json                |  22 ++-
 13 files changed, 397 insertions(+), 167 deletions(-)
 create mode 100644 hobo/provisionning/__init__.py
 create mode 100644 hobo/provisionning/middleware.py
 create mode 100644 hobo/provisionning/utils.py

diff --git a/debian/debian_config_common.py b/debian/debian_config_common.py
index c43c5e9..22a4501 100644
--- a/debian/debian_config_common.py
+++ b/debian/debian_config_common.py
@@ -264,6 +264,7 @@ if 'MIDDLEWARE_CLASSES' in globals():
     if PROJECT_NAME != 'wcs' and 'authentic2' not in INSTALLED_APPS:
         MIDDLEWARE_CLASSES = MIDDLEWARE_CLASSES + (
             'mellon.middleware.PassiveAuthenticationMiddleware',
+            'hobo.provisionning.middleware.ProvisionningMiddleware',
         )
 
     if 'authentic2' in INSTALLED_APPS:
@@ -282,6 +283,7 @@ else:
     if PROJECT_NAME != 'wcs' and 'authentic2' not in INSTALLED_APPS:
         MIDDLEWARE = MIDDLEWARE + (
             'mellon.middleware.PassiveAuthenticationMiddleware',
+            'hobo.provisionning.middleware.ProvisionningMiddleware',
         )
 
     if 'authentic2' in INSTALLED_APPS:
diff --git a/hobo/agent/authentic2/provisionning.py b/hobo/agent/authentic2/provisionning.py
index 35c558e..7fb5730 100644
--- a/hobo/agent/authentic2/provisionning.py
+++ b/hobo/agent/authentic2/provisionning.py
@@ -3,6 +3,7 @@ from django.utils.six.moves.urllib.parse import urljoin
 import threading
 import copy
 import logging
+import requests
 
 from django.contrib.auth import get_user_model
 from django.db import connection
@@ -12,6 +13,7 @@ from django.utils.encoding import force_text
 
 from django_rbac.utils import get_role_model, get_ou_model, get_role_parenting_model
 from hobo.agent.common import notify_agents
+from hobo.signature import sign_url
 from authentic2.saml.models import LibertyProvider
 from authentic2.a2_rbac.models import RoleAttribute
 from authentic2.models import AttributeValue
@@ -164,7 +166,7 @@ class Provisionning(threading.local):
                     for service, audience in self.get_audience(ou):
                         for user in users:
                             logger.info(u'provisionning user %s to %s', user, audience)
-                            notify_agents({
+                            self.notify_agents({
                                 '@type': 'provision',
                                 'issuer': issuer,
                                 'audience': [audience],
@@ -181,7 +183,7 @@ class Provisionning(threading.local):
                         continue
                     logger.info(u'provisionning users %s to %s', u', '.join(
                         map(force_text, users)), u', '.join(audience))
-                    notify_agents({
+                    self.notify_agents({
                         '@type': 'provision',
                         'issuer': issuer,
                         'audience': audience,
@@ -196,7 +198,7 @@ class Provisionning(threading.local):
                         for s, audience in self.get_audience(ou)]
             logger.info(u'deprovisionning users %s from %s', u', '.join(
                 map(force_text, users)), u', '.join(audience))
-            notify_agents({
+            self.notify_agents({
                 '@type': 'deprovision',
                 'issuer': issuer,
                 'audience': audience,
@@ -249,7 +251,7 @@ class Provisionning(threading.local):
 
             audience = [entity_id for service, entity_id in self.get_audience(ou)]
             logger.info(u'%sning roles %s to %s', mode, roles, audience)
-            notify_agents({
+            self.notify_agents({
                 '@type': mode,
                 'audience': audience,
                 'full': full,
@@ -401,3 +403,32 @@ class Provisionning(threading.local):
                 if not reverse:
                     for other_instance in instance.members.all():
                         self.add_saved(other_instance)
+
+    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)
+
+        if data['audience']:
+            notify_agents(data)
diff --git a/hobo/agent/common/management/commands/hobo_notify.py b/hobo/agent/common/management/commands/hobo_notify.py
index 08de7f8..0ee77cd 100644
--- a/hobo/agent/common/management/commands/hobo_notify.py
+++ b/hobo/agent/common/management/commands/hobo_notify.py
@@ -17,23 +17,16 @@
 import json
 import os
 import sys
-import random
-import logging
 
 from django.core.management.base import BaseCommand
-from django.db.transaction import atomic
-from django.db import IntegrityError
 
 from tenant_schemas.utils import tenant_context
 from hobo.multitenant.middleware import TenantMiddleware
-from hobo.multitenant.utils import provision_user_groups
 
-from hobo.agent.common.models import Role
+from hobo.provisionning.utils import NotificationProcessing, TryAgain
 
-class TryAgain(Exception):
-    pass
 
-class Command(BaseCommand):
+class Command(BaseCommand, NotificationProcessing):
     requires_system_checks = False
 
     def add_arguments(self, parser):
@@ -55,135 +48,6 @@ class Command(BaseCommand):
             with tenant_context(tenant):
                 self.process_notification(tenant, notification)
 
-    @classmethod
-    def check_valid_notification(cls, notification):
-        return isinstance(notification, dict) \
-            and '@type' in notification \
-            and notification['@type'] in ['provision', 'deprovision'] \
-            and 'objects' in notification \
-            and 'audience' in notification \
-            and isinstance(notification['audience'], list) \
-            and isinstance(notification['objects'], dict)
-
-    @classmethod
-    def check_valid_role(cls, o):
-        return 'uuid' in o \
-               and 'name' in o \
-               and 'description' in o
-
-    @classmethod
-    def check_valid_user(cls, o):
-        return 'uuid' in o \
-               and 'is_superuser' in o \
-               and 'email' in o \
-               and 'first_name' in o \
-               and 'last_name' in o \
-               and 'roles' in o
-
-    @classmethod
-    def provision_user(cls, issuer, action, data, full=False):
-        from django.contrib.auth import get_user_model
-        from mellon.models import UserSAMLIdentifier
-        User = get_user_model()
-
-
-        assert not full  # provisionning all users is dangerous, we prefer deprovision
-        uuids = set()
-        for o in data:
-            try:
-                with atomic():
-                    if action == 'provision':
-                        assert cls.check_valid_user(o)
-                        try:
-                            mellon_user = UserSAMLIdentifier.objects.get(
-                                issuer=issuer, name_id=o['uuid'])
-                            user = mellon_user.user
-                        except UserSAMLIdentifier.DoesNotExist:
-                            try:
-                                user = User.objects.get(username=o['uuid'][:30])
-                            except User.DoesNotExist:
-                                # temp user object
-                                random_uid = str(random.randint(1,10000000000000))
-                                user = User.objects.create(
-                                    username=random_uid)
-                            mellon_user = UserSAMLIdentifier.objects.create(
-                                user=user, issuer=issuer, name_id=o['uuid'])
-                        user.first_name = o['first_name'][:30]
-                        user.last_name = o['last_name'][:30]
-                        user.email = o['email'][:75]
-                        user.username = o['uuid'][:30]
-                        user.is_superuser = o['is_superuser']
-                        user.is_staff = o['is_superuser']
-                        user.save()
-                        role_uuids = [role['uuid'] for role in o.get('roles', [])]
-                        provision_user_groups(user, role_uuids)
-                    elif action == 'deprovision':
-                        assert 'uuid' in o
-                uuids.add(o['uuid'])
-            except IntegrityError:
-                raise TryAgain
-        if full and action == 'provision':
-            for usi in UserSAMLIdentifier.objects.exclude(name_id__in=uuids):
-                usi.user.delete()
-        elif action == 'deprovision':
-            for user in User.objects.filter(saml_identifiers__name_id__in=uuids):
-                user.delete()
-
-    @classmethod
-    def provision_role(cls, issuer, action, data, full=False):
-        logger = logging.getLogger(__name__)
-        uuids = set()
-        for o in data:
-            assert 'uuid' in o
-            uuids.add(o['uuid'])
-            if action == 'provision':
-                assert cls.check_valid_role(o)
-                role_name = o['name']
-                if len(role_name) > 70:
-                    role_name = role_name[:70] + '(...)'
-                try:
-                    role = Role.objects.get(uuid=o['uuid'])
-                    created = False
-                except Role.DoesNotExist:
-                    try:
-                        with atomic():
-                            role, created = Role.objects.get_or_create(
-                                name=role_name, defaults={
-                                    'uuid': o['uuid'],
-                                    'description': o['description']})
-                    except IntegrityError:
-                        # Can happen if uuid and name already exist
-                        logger.error(u'cannot provision role "%s" (%s)', o['name'], o['uuid'])
-                        continue
-                if not created:
-                    save = False
-                    if role.name != role_name:
-                        role.name = role_name
-                        save = True
-                    if role.uuid != o['uuid']:
-                        role.uuid = o['uuid']
-                        save = True
-                    if role.description != o['description']:
-                        role.description = o['description']
-                        save = True
-                    if role.details != o.get('details', u''):
-                        role.details = o.get('details', u'')
-                        save = True
-                    if save:
-                        try:
-                            with atomic():
-                                role.save()
-                        except IntegrityError:
-                            # Can happen if uuid and name already exist
-                            logger.error(u'cannot provision role "%s" (%s)', o['name'], o['uuid'])
-                            continue
-        if full and action == 'provision':
-            for role in Role.objects.exclude(uuid__in=uuids):
-                role.delete()
-        elif action == 'deprovision':
-            for role in Role.objects.filter(uuid__in=uuids):
-                role.delete()
-
     @classmethod
     def process_notification(cls, tenant, notification):
         assert cls.check_valid_notification(notification), \
@@ -197,7 +61,6 @@ class Command(BaseCommand):
         assert entity_id, 'service has no saml-sp-metadat-url field'
         if entity_id not in audience:
             return
-        uuids = set()
         object_type = notification['objects']['@type']
         for i in range(20):
             try:
diff --git a/hobo/environment/models.py b/hobo/environment/models.py
index 932fa8c..f8c4cf7 100644
--- a/hobo/environment/models.py
+++ b/hobo/environment/models.py
@@ -155,6 +155,8 @@ class ServiceBase(models.Model):
             as_dict['saml-idp-metadata-url'] = self.get_saml_idp_metadata_url()
         if self.get_backoffice_menu_url():
             as_dict['backoffice-menu-url'] = self.get_backoffice_menu_url()
+        if self.get_provisionning_url():
+            as_dict['provisionning-url'] = self.get_provisionning_url()
         return as_dict
 
     @property
@@ -207,6 +209,9 @@ class ServiceBase(models.Model):
     def get_backoffice_menu_url(self):
         return None
 
+    def get_provisionning_url(self):
+        return self.get_base_url_path() + '__provision__/'
+
     def is_resolvable(self):
         try:
             netloc = urlparse(self.base_url).netloc
@@ -298,6 +303,9 @@ class Authentic(ServiceBase):
     def get_backoffice_menu_url(self):
         return self.get_base_url_path() + 'manage/menu.json'
 
+    def get_provisionning_url(self):
+        return None
+
 
 class Wcs(ServiceBase):
     class Meta:
diff --git a/hobo/environment/utils.py b/hobo/environment/utils.py
index 1973644..fee3792 100644
--- a/hobo/environment/utils.py
+++ b/hobo/environment/utils.py
@@ -14,6 +14,8 @@
 # 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/>.
 
+import hashlib
+
 from django.conf import settings
 from django.urls import reverse
 from django.db import connection
@@ -21,6 +23,7 @@ from django.utils.six.moves.urllib.parse import urlparse
 from django.utils.encoding import force_text
 
 from hobo.middleware.utils import StoreRequestMiddleware
+from hobo.multitenant.settings_loaders import KnownServices
 
 
 def get_installed_services():
@@ -35,9 +38,13 @@ def get_operational_services():
     return [x for x in get_installed_services() if x.is_operational()]
 
 
-def get_installed_services_dict():
-    from .models import Variable
-    hobo_service = []
+def get_local_key(url):
+    secret1 = force_text(settings.SECRET_KEY)
+    secret2 = url
+    return KnownServices.shared_secret(secret1, secret2)[:40]
+
+
+def get_local_hobo_dict():
     build_absolute_uri = None
     if hasattr(connection, 'get_tenant') and hasattr(connection.get_tenant(), 'build_absolute_uri'):
         build_absolute_uri = connection.get_tenant().build_absolute_uri
@@ -45,16 +52,27 @@ def get_installed_services_dict():
         request = StoreRequestMiddleware.get_request()
         if request:
             build_absolute_uri = request.build_absolute_uri
-    if build_absolute_uri:
-        # if there's a known base url hobo can advertise itself.
-        hobo_service = [{
-            'service-id': 'hobo',
-            'title': 'Hobo',
-            'slug': 'hobo',
-            'base_url': build_absolute_uri(reverse('home')),
-            'saml-sp-metadata-url': build_absolute_uri(reverse('mellon_metadata')),
-            'backoffice-menu-url': build_absolute_uri(reverse('menu_json')),
-        }]
+    if not build_absolute_uri:
+        return None
+    # if there's a known base url hobo can advertise itself.
+    return {
+        'secret_key': get_local_key(build_absolute_uri('/')),
+        'service-id': 'hobo',
+        'title': 'Hobo',
+        'slug': 'hobo',
+        'base_url': build_absolute_uri(reverse('home')),
+        'saml-sp-metadata-url': build_absolute_uri(reverse('mellon_metadata')),
+        'backoffice-menu-url': build_absolute_uri(reverse('menu_json')),
+        'provisionning-url': build_absolute_uri('/__provision__/'),
+    }
+
+
+def get_installed_services_dict():
+    from .models import Variable
+    hobo_service = []
+    hobo_dict = get_local_hobo_dict()
+    if hobo_dict:
+        hobo_service.append(hobo_dict)
     return {
         'services': hobo_service + [x.as_dict() for x in get_installed_services()],
         'variables': {v.name: v.json for v in Variable.objects.filter(service_pk__isnull=True)}
diff --git a/hobo/multitenant/settings_loaders.py b/hobo/multitenant/settings_loaders.py
index 4d3d967..634d1bd 100644
--- a/hobo/multitenant/settings_loaders.py
+++ b/hobo/multitenant/settings_loaders.py
@@ -84,6 +84,8 @@ class KnownServices(FileBaseSettingsLoader):
             service_data = {
                 'url': url,
                 'backoffice-menu-url': service.get('backoffice-menu-url'),
+                'provisionning-url': service.get('provisionning-url'),
+                'saml-sp-metadata-url': service.get('saml-sp-metadata-url'),
                 'title': service.get('title'),
                 'orig': orig,
                 'verif_orig': verif_orig,
diff --git a/hobo/provisionning/__init__.py b/hobo/provisionning/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/hobo/provisionning/middleware.py b/hobo/provisionning/middleware.py
new file mode 100644
index 0000000..3e0f2cc
--- /dev/null
+++ b/hobo/provisionning/middleware.py
@@ -0,0 +1,81 @@
+# hobo - portal to configure and deploy applications
+# Copyright (C) 2015-2020  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/>.
+
+import json
+
+from django.conf import settings
+from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseForbidden
+from django.utils.encoding import force_text
+from django.utils.deprecation import MiddlewareMixin
+from django.utils.six.moves.urllib.parse import urlparse
+
+from hobo.provisionning.utils import NotificationProcessing, TryAgain
+from hobo.rest_authentication import PublikAuthentication, PublikAuthenticationFailed
+
+
+class ProvisionningMiddleware(MiddlewareMixin, NotificationProcessing):
+    def process_request(self, request):
+        if not (request.method == 'PUT' and request.path == '/__provision__/'):
+            return None
+        if 'hobo.environment' in settings.INSTALLED_APPS:
+            # much ado about hobo
+            from hobo.environment.utils import get_local_hobo_dict
+            known_services = getattr(settings, 'KNOWN_SERVICES', None)
+            local_hobo_dict = get_local_hobo_dict()
+            if not known_services:
+                # hobo in a single deployment instance
+                settings.KNOWN_SERVICES = known_services = {}
+                known_services['hobo'] = {'hobo': local_hobo_dict}
+                known_services['authentic'] = {'idp': {}}
+            if known_services['hobo']['hobo']['provisionning-url'] == local_hobo_dict['provisionning-url']:
+                # hobo in a single deployment instance, or primary hobo in a
+                # multi-instances environment
+                from hobo.environment.models import Authentic
+                from hobo.multitenant.settings_loaders import KnownServices
+                authentic = Authentic.objects.all().first()
+                orig = urlparse(authentic.base_url).netloc.split(':')[0]
+                # create stub settings.KNOWN_SERVICES with just enough to get
+                # authentication passing.
+                idp_service = list(settings.KNOWN_SERVICES['authentic'].values())[0]
+                idp_service['verif_orig'] = orig
+                idp_service['secret_key'] = KnownServices.shared_secret(
+                        authentic.secret_key, local_hobo_dict['secret_key'])
+        try:
+            PublikAuthentication().authenticate(request)
+        except PublikAuthenticationFailed:
+            return HttpResponseForbidden()
+        try:
+            notification = json.loads(force_text(request.body))
+        except ValueError:
+            return HttpResponseBadRequest()
+        if not isinstance(notification, dict) or 'objects' not in notification:
+            return HttpResponseBadRequest()
+
+        object_type = notification['objects'].get('@type')
+        issuer = notification.get('issuer')
+        action = notification.get('@type')
+        if not (object_type and issuer and action):
+            return HttpResponseBadRequest()
+        full = notification['full'] if 'full' in notification else False
+
+        for i in range(20):
+            try:
+                getattr(self, 'provision_' + object_type)(issuer, action, notification['objects']['data'], full=full)
+            except TryAgain:
+                continue
+            break
+
+        return JsonResponse({'err': 0})
diff --git a/hobo/provisionning/utils.py b/hobo/provisionning/utils.py
new file mode 100644
index 0000000..980189c
--- /dev/null
+++ b/hobo/provisionning/utils.py
@@ -0,0 +1,159 @@
+# hobo - portal to configure and deploy applications
+# Copyright (C) 2015-2020  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/>.
+
+import random
+import logging
+
+from django.db.transaction import atomic
+from django.db import IntegrityError
+
+from hobo.multitenant.utils import provision_user_groups
+from hobo.agent.common.models import Role
+
+
+class TryAgain(Exception):
+    pass
+
+
+class NotificationProcessing:
+
+    @classmethod
+    def check_valid_notification(cls, notification):
+        return isinstance(notification, dict) \
+            and '@type' in notification \
+            and notification['@type'] in ['provision', 'deprovision'] \
+            and 'objects' in notification \
+            and 'audience' in notification \
+            and isinstance(notification['audience'], list) \
+            and isinstance(notification['objects'], dict)
+
+    @classmethod
+    def check_valid_role(cls, o):
+        return 'uuid' in o \
+               and 'name' in o \
+               and 'description' in o
+
+    @classmethod
+    def check_valid_user(cls, o):
+        return 'uuid' in o \
+               and 'is_superuser' in o \
+               and 'email' in o \
+               and 'first_name' in o \
+               and 'last_name' in o \
+               and 'roles' in o
+
+    @classmethod
+    def provision_user(cls, issuer, action, data, full=False):
+        from django.contrib.auth import get_user_model
+        from mellon.models import UserSAMLIdentifier
+        User = get_user_model()
+
+        assert not full  # provisionning all users is dangerous, we prefer deprovision
+        uuids = set()
+        for o in data:
+            try:
+                with atomic():
+                    if action == 'provision':
+                        assert cls.check_valid_user(o)
+                        try:
+                            mellon_user = UserSAMLIdentifier.objects.get(
+                                issuer=issuer, name_id=o['uuid'])
+                            user = mellon_user.user
+                        except UserSAMLIdentifier.DoesNotExist:
+                            try:
+                                user = User.objects.get(username=o['uuid'][:30])
+                            except User.DoesNotExist:
+                                # temp user object
+                                random_uid = str(random.randint(1, 10000000000000))
+                                user = User.objects.create(
+                                    username=random_uid)
+                            mellon_user = UserSAMLIdentifier.objects.create(
+                                user=user, issuer=issuer, name_id=o['uuid'])
+                        user.first_name = o['first_name'][:30]
+                        user.last_name = o['last_name'][:30]
+                        user.email = o['email'][:75]
+                        user.username = o['uuid'][:30]
+                        user.is_superuser = o['is_superuser']
+                        user.is_staff = o['is_superuser']
+                        user.save()
+                        role_uuids = [role['uuid'] for role in o.get('roles', [])]
+                        provision_user_groups(user, role_uuids)
+                    elif action == 'deprovision':
+                        assert 'uuid' in o
+                uuids.add(o['uuid'])
+            except IntegrityError:
+                raise TryAgain
+        if full and action == 'provision':
+            for usi in UserSAMLIdentifier.objects.exclude(name_id__in=uuids):
+                usi.user.delete()
+        elif action == 'deprovision':
+            for user in User.objects.filter(saml_identifiers__name_id__in=uuids):
+                user.delete()
+
+    @classmethod
+    def provision_role(cls, issuer, action, data, full=False):
+        logger = logging.getLogger(__name__)
+        uuids = set()
+        for o in data:
+            assert 'uuid' in o
+            uuids.add(o['uuid'])
+            if action == 'provision':
+                assert cls.check_valid_role(o)
+                role_name = o['name']
+                if len(role_name) > 70:
+                    role_name = role_name[:70] + '(...)'
+                try:
+                    role = Role.objects.get(uuid=o['uuid'])
+                    created = False
+                except Role.DoesNotExist:
+                    try:
+                        with atomic():
+                            role, created = Role.objects.get_or_create(
+                                name=role_name, defaults={
+                                    'uuid': o['uuid'],
+                                    'description': o['description']})
+                    except IntegrityError:
+                        # Can happen if uuid and name already exist
+                        logger.error(u'cannot provision role "%s" (%s)', o['name'], o['uuid'])
+                        continue
+                if not created:
+                    save = False
+                    if role.name != role_name:
+                        role.name = role_name
+                        save = True
+                    if role.uuid != o['uuid']:
+                        role.uuid = o['uuid']
+                        save = True
+                    if role.description != o['description']:
+                        role.description = o['description']
+                        save = True
+                    if role.details != o.get('details', u''):
+                        role.details = o.get('details', u'')
+                        save = True
+                    if save:
+                        try:
+                            with atomic():
+                                role.save()
+                        except IntegrityError:
+                            # Can happen if uuid and name already exist
+                            logger.error(u'cannot provision role "%s" (%s)', o['name'], o['uuid'])
+                            continue
+        if full and action == 'provision':
+            for role in Role.objects.exclude(uuid__in=uuids):
+                role.delete()
+        elif action == 'deprovision':
+            for role in Role.objects.filter(uuid__in=uuids):
+                role.delete()
diff --git a/tests/settings.py b/tests/settings.py
index 98d6fc8..a685e7c 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -2,12 +2,14 @@ LANGUAGE_CODE = 'en-us'
 BROKER_URL = 'memory://'
 OZWILLO_SECRET = 'secret'
 
-INSTALLED_APPS += ('hobo.contrib.ozwillo',)
+INSTALLED_APPS += ('hobo.contrib.ozwillo', 'hobo.agent.common')
 
 ALLOWED_HOSTS.append('localhost')
 
 TEMPLATES[0]['OPTIONS']['debug'] = True
 
-MIDDLEWARE_CLASSES = ('hobo.middleware.RobotsTxtMiddleware', ) + MIDDLEWARE_CLASSES
+MIDDLEWARE_CLASSES = MIDDLEWARE_CLASSES + (
+        'hobo.middleware.RobotsTxtMiddleware',
+        'hobo.provisionning.middleware.ProvisionningMiddleware')
 
 HOBO_MANAGER_HOMEPAGE_URL_VAR = 'portal_agent_url'
diff --git a/tests_authentic/test_provisionning.py b/tests_authentic/test_provisionning.py
index 429d959..dfb618f 100644
--- a/tests_authentic/test_provisionning.py
+++ b/tests_authentic/test_provisionning.py
@@ -535,3 +535,61 @@ def test_middleware(notify_agents, app_factory, tenant, settings):
     assert notify_agents.call_count == 0
     resp = resp.form.submit().follow()
     assert notify_agents.call_count == 1
+
+
+def test_provision_using_http(transactional_db, tenant, settings, caplog):
+    with tenant_context(tenant):
+        # create providers so notification messages have an audience.
+        LibertyProvider.objects.create(ou=None, name='provider', slug='provider',
+                                       entity_id='http://example.org',
+                                       protocol_conformance=lasso.PROTOCOL_SAML_2_0)
+        LibertyProvider.objects.create(ou=None, name='provider2', slug='provider2',
+                                       entity_id='http://example.com',
+                                       protocol_conformance=lasso.PROTOCOL_SAML_2_0)
+    with patch('hobo.agent.authentic2.provisionning.notify_agents') as notify_agents:
+        call_command('createsuperuser', domain=tenant.domain_url, uuid='coin',
+                     username='coin', email='coin@coin.org', interactive=False)
+        assert notify_agents.call_count == 1
+        assert set(notify_agents.call_args[0][0]['audience']) == {'http://example.org', 'http://example.com'}
+
+    settings.HOBO_HTTP_PROVISIONNING = True
+    with patch('hobo.agent.authentic2.provisionning.notify_agents') as notify_agents:
+        call_command('createsuperuser', domain=tenant.domain_url, uuid='coin2',
+                     username='coin2', email='coin2@coin.org', interactive=False)
+        assert notify_agents.call_count == 1
+        assert set(notify_agents.call_args[0][0]['audience']) == {'http://example.org', 'http://example.com'}
+
+    settings.HOBO_HTTP_PROVISIONNING = True
+    settings.KNOWN_SERVICES = {
+        'foo': {
+            'bar': {
+                'saml-sp-metadata-url': 'http://example.org',
+                'provisionning-url': 'http://example.org/__provision__/',
+                'orig': 'example.org',
+                'secret': 'xxx',
+            }
+        }
+    }
+    with patch('hobo.agent.authentic2.provisionning.notify_agents') as notify_agents:
+        with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put:
+            call_command('createsuperuser', domain=tenant.domain_url, uuid='coin2',
+                         username='coin2', email='coin2@coin.org', interactive=False)
+            assert notify_agents.call_count == 1
+            assert notify_agents.call_args[0][0]['audience'] == ['http://example.com']
+            assert requests_put.call_count == 1
+            # cannot check audience passed to requests.put as it's the same
+            # dictionary that is altered afterwards and would thus also contain
+            # http://example.com.
+
+    settings.KNOWN_SERVICES['foo']['bar2'] = {
+        'saml-sp-metadata-url': 'http://example.com',
+        'provisionning-url': 'http://example.com/__provision__/',
+        'orig': 'example.com',
+        'secret': 'xxx',
+    }
+    with patch('hobo.agent.authentic2.provisionning.notify_agents') as notify_agents:
+        with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put:
+            call_command('createsuperuser', domain=tenant.domain_url, uuid='coin2',
+                         username='coin2', email='coin2@coin.org', interactive=False)
+            assert notify_agents.call_count == 0
+            assert requests_put.call_count == 2
diff --git a/tests_multitenant/test_settings.py b/tests_multitenant/test_settings.py
index 98700f4..6ad1e61 100644
--- a/tests_multitenant/test_settings.py
+++ b/tests_multitenant/test_settings.py
@@ -245,7 +245,7 @@ def test_known_services(tenants, settings):
             assert 'other' in settings.KNOWN_SERVICES['authentic']
             assert (set(['url', 'backoffice-menu-url', 'title', 'orig',
                          'verif_orig', 'secret', 'template_name', 'variables',
-                         'secondary'])
+                         'saml-sp-metadata-url', 'provisionning-url', 'secondary'])
                     == set(settings.KNOWN_SERVICES['authentic']['other'].keys()))
             assert (settings.KNOWN_SERVICES['authentic']['other']['url']
                     == hobo_json['services'][2]['base_url'])
diff --git a/tests_schemas/example_env.json b/tests_schemas/example_env.json
index 5ac91a7..9069eab 100644
--- a/tests_schemas/example_env.json
+++ b/tests_schemas/example_env.json
@@ -139,7 +139,9 @@
         {
             "backoffice-menu-url": "https://hobo-instance-name.dev.signalpublik.com/menu.json",
             "base_url": "https://hobo-instance-name.dev.signalpublik.com/",
+            "provisionning-url": "https://hobo-instance-name.dev.signalpublik.com/__provision__/",
             "saml-sp-metadata-url": "https://hobo-instance-name.dev.signalpublik.com/accounts/mellon/metadata/",
+            "secret_key": "XXX",
             "service-id": "hobo",
             "slug": "hobo",
             "title": "Hobo"
@@ -150,22 +152,23 @@
             "id": 1,
             "saml-idp-metadata-url": "https://connexion-instance-name.dev.signalpublik.com/idp/saml2/metadata",
             "secondary": false,
-            "secret_key": "k_a)vo)a&8xugbzjl#%^s8vfkm2+#yhz#if4m+xu!qqv=04x9q",
+            "secret_key": "XXX",
             "service-id": "authentic",
             "service-label": "Authentic",
             "slug": "idp",
             "template_name": "signal-publik",
             "title": "Connexion",
-            "variables": {},
-            "use_as_idp_for_self": true
+            "use_as_idp_for_self": true,
+            "variables": {}
         },
         {
             "backoffice-menu-url": "https://demarches-instance-name.dev.signalpublik.com/backoffice/menu.json",
             "base_url": "https://demarches-instance-name.dev.signalpublik.com/",
             "id": 1,
+            "provisionning-url": "https://demarches-instance-name.dev.signalpublik.com/__provision__/",
             "saml-sp-metadata-url": "https://demarches-instance-name.dev.signalpublik.com/saml/metadata",
             "secondary": false,
-            "secret_key": "uhipz^y38a*w#rrnio_-i=+7p47aq#$+dntm*i@nz(y)n57153",
+            "secret_key": "XXX",
             "service-id": "wcs",
             "service-label": "w.c.s.",
             "slug": "eservices",
@@ -177,9 +180,10 @@
             "backoffice-menu-url": "https://passerelle-instance-name.dev.signalpublik.com/manage/menu.json",
             "base_url": "https://passerelle-instance-name.dev.signalpublik.com/",
             "id": 1,
+            "provisionning-url": "https://passerelle-instance-name.dev.signalpublik.com/__provision__/",
             "saml-sp-metadata-url": "https://passerelle-instance-name.dev.signalpublik.com/accounts/mellon/metadata/",
             "secondary": false,
-            "secret_key": "vz&g(p1bhzw35iltrrl$^6013*+q80l&l4)b)tsr=+ko__js_v",
+            "secret_key": "XXX",
             "service-id": "passerelle",
             "service-label": "Passerelle",
             "slug": "passerelle",
@@ -191,9 +195,10 @@
             "backoffice-menu-url": "https://instance-name.dev.signalpublik.com/manage/menu.json",
             "base_url": "https://instance-name.dev.signalpublik.com/",
             "id": 1,
+            "provisionning-url": "https://instance-name.dev.signalpublik.com/__provision__/",
             "saml-sp-metadata-url": "https://instance-name.dev.signalpublik.com/accounts/mellon/metadata/",
             "secondary": false,
-            "secret_key": "^0!psa-ijq4*va0a4&_)solvils#hig2vtof(%3iy#!6p5!f6e",
+            "secret_key": "XXX",
             "service-id": "combo",
             "service-label": "Combo",
             "slug": "portal",
@@ -205,9 +210,10 @@
             "backoffice-menu-url": "https://agents-instance-name.dev.signalpublik.com/manage/menu.json",
             "base_url": "https://agents-instance-name.dev.signalpublik.com/",
             "id": 2,
+            "provisionning-url": "https://agents-instance-name.dev.signalpublik.com/__provision__/",
             "saml-sp-metadata-url": "https://agents-instance-name.dev.signalpublik.com/accounts/mellon/metadata/",
             "secondary": false,
-            "secret_key": "m1&vql=pm-clw)0wcnk=q4g1-#flrus!dui$gr$7ug2%xw@ko$",
+            "secret_key": "XXX",
             "service-id": "combo",
             "service-label": "Combo",
             "slug": "portal-agent",
@@ -216,7 +222,7 @@
             "variables": {}
         }
     ],
-    "timestamp": "1558975192.98",
+    "timestamp": "XXXXXXXXXX.XX",
     "users": [],
     "variables": {
         "css_variant": "publik",
-- 
2.27.0.rc2

