Projet

Général

Profil

0001-authentic-add-API-to-force-user-provisionning-53059.patch

Frédéric Péters, 15 avril 2021 09:11

Télécharger (16,4 ko)

Voir les différences:

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
hobo/agent/authentic2/apps.py
19 19
from django.conf import settings
20 20

  
21 21

  
22
class Plugin:
23
    def get_before_urls(self):
24
        from . import urls
25
        return urls.urlpatterns
26

  
27

  
22 28
class Authentic2AgentConfig(AppConfig):
23 29
    name = 'hobo.agent.authentic2'
24 30
    label = 'authentic2_agent'
25 31
    verbose_name = 'Authentic2 Agent'
26 32

  
33
    def get_a2_plugin(self):
34
        return Plugin()
35

  
27 36
    def ready(self):
28 37
        from . import provisionning
29 38

  
hobo/agent/authentic2/provisionning.py
424 424

  
425 425
    def notify_agents(self, data):
426 426
        if getattr(settings, 'HOBO_HTTP_PROVISIONNING', False):
427
            services_by_url = {}
428
            for services in settings.KNOWN_SERVICES.values():
429
                for service in services.values():
430
                    if service.get('provisionning-url'):
431
                        services_by_url[service['saml-sp-metadata-url']] = service
432
            audience = data.get('audience')
433
            rest_audience = [x for x in audience if x in services_by_url]
434
            amqp_audience = audience
435
            for audience in rest_audience:
436
                service = services_by_url[audience]
437
                data['audience'] = [audience]
438
                try:
439
                    response = requests.put(
440
                        sign_url(service['provisionning-url'] + '?orig=%s' % service['orig'], service['secret']),
441
                        json=data)
442
                    response.raise_for_status()
443
                except requests.RequestException as e:
444
                    logger.error(u'error provisionning to %s (%s)', audience, e)
445
                else:
446
                    amqp_audience.remove(audience)
447
            data['audience'] = amqp_audience
448
            if amqp_audience:
449
                logger.info(u'leftover AMQP audience: %s', amqp_audience)
427
            leftover_audience = self.notify_agents_http(data)
428
            if not leftover_audience:
429
                return
430
            logger.info('leftover AMQP audience: %s', leftover_audience)
431
            data['audience'] = leftover_audience
450 432

  
451 433
        if data['audience']:
452 434
            notify_agents(data)
435

  
436
    def get_http_services_by_url(self):
437
        services_by_url = {}
438
        for services in settings.KNOWN_SERVICES.values():
439
            for service in services.values():
440
                if service.get('provisionning-url'):
441
                    services_by_url[service['saml-sp-metadata-url']] = service
442
        return services_by_url
443

  
444
    def notify_agents_http(self, data):
445
        services_by_url = self.get_http_services_by_url()
446
        audience = data.get('audience')
447
        rest_audience = [x for x in audience if x in services_by_url]
448
        leftover_audience = audience
449
        for audience in rest_audience:
450
            service = services_by_url[audience]
451
            data['audience'] = [audience]
452
            try:
453
                response = requests.put(
454
                    sign_url(service['provisionning-url'] + '?orig=%s' % service['orig'], service['secret']),
455
                    json=data)
456
                response.raise_for_status()
457
            except requests.RequestException as e:
458
                logger.error(u'error provisionning to %s (%s)', audience, e)
459
            else:
460
                leftover_audience.remove(audience)
461
        return leftover_audience
hobo/agent/authentic2/urls.py
1
# hobo - portal to configure and deploy applications
2
# Copyright (C) 2015-2021  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.conf.urls import url
18

  
19
from . import views
20

  
21
urlpatterns = [
22
    url(r'^api/provision/$', views.provision_view),
23
]
hobo/agent/authentic2/views.py
1
# hobo - portal to configure and deploy applications
2
# Copyright (C) 2015-2021  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.conf import settings
18

  
19
from rest_framework import permissions, serializers, status
20
from rest_framework.response import Response
21
from rest_framework.generics import GenericAPIView
22

  
23
from . import provisionning
24

  
25

  
26
class ProvisionSerializer(serializers.Serializer):
27
    user_uuid = serializers.CharField(required=False)
28
    role_uuid = serializers.CharField(required=False)
29
    service_type = serializers.CharField(required=False)
30
    service_url = serializers.CharField(required=False)
31

  
32
    def validate(self, data):
33
        if not (data.get('user_uuid') or data.get('role_uuid')):
34
            raise serializers.ValidationError('must provide user_uuid or role_uuid')
35
        if data.get('user_uuid') and data.get('role_uuid'):
36
            raise serializers.ValidationError('cannot provision both user & role')
37
        return data
38

  
39

  
40
class ProvisionView(GenericAPIView):
41
    permission_classes = (permissions.IsAuthenticated,)
42
    serializer_class = ProvisionSerializer
43

  
44
    def post(self, request):
45
        serializer = self.get_serializer(data=request.data)
46
        if not serializer.is_valid():
47
            return Response({'err': 1, 'errors': serializer.errors}, status.HTTP_400_BAD_REQUEST)
48

  
49
        engine = ApiProvisionningEngine(
50
            service_type=serializer.validated_data.get('service_type'),
51
            service_url=serializer.validated_data.get('service_url'),
52
        )
53

  
54
        user_uuid = serializer.validated_data.get('user_uuid')
55
        role_uuid = serializer.validated_data.get('role_uuid')
56
        if user_uuid:
57
            try:
58
                user = provisionning.User.objects.get(uuid=user_uuid)
59
            except provisionning.User.DoesNotExist:
60
                return Response({'err': 1, 'err_desc': 'unknown user UUID'})
61
            engine.notify_users(ous=None, users=[user])
62
        elif role_uuid:
63
            try:
64
                role = provisionning.Role.objects.get(uuid=role_uuid)
65
            except provisionning.Role.DoesNotExist:
66
                return Response({'err': 1, 'err_desc': 'unknown role UUID'})
67
            ous = {ou.id: ou for ou in provisionning.OU.objects.all()}
68
            engine.notify_roles(ous=ous, roles=[role])
69

  
70
        response = {
71
            'err': 0,
72
            'leftover_audience': engine.leftover_audience,
73
            'reached_audience': engine.reached_audience,
74
        }
75
        if engine.leftover_audience:
76
            response['err'] = 1
77
        return Response(response)
78

  
79

  
80
provision_view = ProvisionView.as_view()
81

  
82

  
83
class ApiProvisionningEngine(provisionning.Provisionning):
84
    def __init__(self, service_type=None, service_url=None):
85
        super().__init__()
86
        self.service_type = service_type
87
        self.service_url = service_url
88

  
89
    def get_http_services_by_url(self):
90
        if self.service_type:
91
            services_by_url = {}
92
            for service in settings.KNOWN_SERVICES[self.service_type].values():
93
                if service.get('provisionning-url'):
94
                    services_by_url[service['saml-sp-metadata-url']] = service
95
        else:
96
            services_by_url = super().get_http_services_by_url()
97
        if self.service_url:
98
            services_by_url = {k: v for k, v in services_by_url.items() if self.service_url in v['url']}
99
        return services_by_url
100

  
101
    def notify_agents(self, data):
102
        self.leftover_audience = self.notify_agents_http(data)
103
        # only include filtered services in leftovers
104
        services_by_url = self.get_http_services_by_url()
105
        self.leftover_audience = [x for x in self.leftover_audience if x in services_by_url]
106
        self.reached_audience = [x for x in services_by_url if x not in self.leftover_audience]
tests_authentic/conftest.py
54 54
                        'title': 'Other',
55 55
                        'service-id': 'welco',
56 56
                        'secret_key': 'abcdef',
57
                        'base_url': 'http://other.example.net'
57
                        'url': 'http://other.example.net',
58
                        'base_url': 'http://other.example.net',
59
                        'provisionning-url': 'http://other.example.net/__provision__/',
60
                        'saml-sp-metadata-url': 'http://other.example.net/metadata/',
58 61
                    },
62
                    {
63
                        'slug': 'more',
64
                        'title': 'More',
65
                        'service-id': 'wcs',
66
                        'secret_key': 'abcdef',
67
                        'url': 'http://more.example.net',
68
                        'base_url': 'http://more.example.net',
69
                        'provisionning-url': 'http://more.example.net/__provision__/',
70
                        'saml-sp-metadata-url': 'http://more.example.net/metadata/',
71
                    },
72

  
59 73
                ]
60 74
            }, fd)
61 75
        schema_name = name.replace('-', '_').replace('.', '_')
tests_authentic/test_provisionning.py
1 1
# -*- coding: utf-8 -*-
2

  
2 3
import json
3 4

  
4 5
import pytest
6
import requests
5 7
import lasso
6 8

  
7 9
from mock import patch, call, ANY
......
16 18
from authentic2.a2_rbac.utils import get_default_ou
17 19
from authentic2.models import Attribute, AttributeValue
18 20
from django_rbac.utils import get_ou_model
21

  
19 22
from hobo.agent.authentic2.provisionning import provisionning
23
from hobo import signature
20 24

  
21 25
User = get_user_model()
22 26

  
......
594 598
                         username='coin2', email='coin2@coin.org', interactive=False)
595 599
            assert notify_agents.call_count == 0
596 600
            assert requests_put.call_count == 2
601

  
602

  
603
def test_provisionning_api(transactional_db, app_factory, tenant, settings, caplog):
604
    with tenant_context(tenant):
605
        # create providers so notification messages have an audience.
606
        LibertyProvider.objects.create(ou=get_default_ou(), name='provider', slug='provider',
607
                                       entity_id='http://other.example.net/metadata/',
608
                                       protocol_conformance=lasso.PROTOCOL_SAML_2_0)
609
        LibertyProvider.objects.create(ou=get_default_ou(), name='provider2', slug='provider2',
610
                                       entity_id='http://more.example.net/metadata/',
611
                                       protocol_conformance=lasso.PROTOCOL_SAML_2_0)
612

  
613
        role = Role.objects.create(name='coin', ou=get_default_ou())
614
        user = User.objects.create(username='Étienne',
615
                    email='etienne.dugenou@example.net',
616
                    first_name='Étienne',
617
                    last_name='Dugenou',
618
                    ou=get_default_ou())
619

  
620
    app = app_factory(tenant)
621
    resp = app.post_json('/api/provision/', {}, status=403)
622

  
623
    orig = settings.KNOWN_SERVICES['welco']['other']['verif_orig']
624
    key = settings.KNOWN_SERVICES['welco']['other']['secret']
625
    resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig, key), {}, status=400)
626
    assert resp.json['errors']['__all__'] == ['must provide user_uuid or role_uuid']
627

  
628
    resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig,
629
        key), {'user_uuid': 'xxx', 'role_uuid': 'yyy'}, status=400)
630
    assert resp.json['errors']['__all__'] == ['cannot provision both user & role']
631

  
632
    resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig,
633
        key), {'user_uuid': 'xxx'}, status=200)
634
    assert resp.json == {'err': 1, 'err_desc': 'unknown user UUID'}
635

  
636
    resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig,
637
        key), {'role_uuid': 'xxx'}, status=200)
638
    assert resp.json == {'err': 1, 'err_desc': 'unknown role UUID'}
639

  
640
    with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put:
641
        resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig,
642
            key), {'user_uuid': user.uuid})
643
        assert requests_put.call_count == 2
644
        assert not resp.json['leftover_audience']
645
        assert set(resp.json['reached_audience']) == {
646
                'http://other.example.net/metadata/',
647
                'http://more.example.net/metadata/'}
648

  
649
    with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put:
650
        resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig,
651
            key), {'user_uuid': user.uuid, 'service_type': 'welco'})
652
        assert requests_put.call_count == 1
653
        assert not resp.json['leftover_audience']
654
        assert set(resp.json['reached_audience']) == {'http://other.example.net/metadata/'}
655

  
656
    with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put:
657
        resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig,
658
            key), {'user_uuid': user.uuid, 'service_url': 'example.net'})
659
        assert requests_put.call_count == 2
660

  
661
    with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put:
662
        resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig,
663
            key), {'role_uuid': role.uuid})
664
        assert requests_put.call_count == 2
665
        assert resp.json['err'] == 0
666
        assert not resp.json['leftover_audience']
667

  
668
    with patch('hobo.agent.authentic2.provisionning.requests.put') as requests_put:
669
        requests_put.side_effect = requests.RequestException
670
        resp = app.post_json(signature.sign_url('/api/provision/?orig=%s' % orig,
671
            key), {'role_uuid': role.uuid})
672
        assert resp.json['err'] == 1
673
        assert resp.json['leftover_audience']
597
-